diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index f8e721e0b2eeec6d68fd160e79465c68fb98b5e5..af7d0e912fd6cfa8a90eaffd55ee95d4bcffe1fb 100644 --- a/app/controllers/course/courseware.php +++ b/app/controllers/course/courseware.php @@ -36,6 +36,12 @@ class Course_CoursewareController extends CoursewareController $this->licenses = $this->getLicenses(); $this->oer_enabled = Config::get()->OERCAMPUS_ENABLED && $GLOBALS['perm']->have_perm(Config::get()->OER_PUBLIC_STATUS); $this->unitsNotFound = Unit::countBySql('range_id = ?', [Context::getId()]) === 0; + + $this->feedback_settings = json_encode([ + 'activated' => \Feedback::isActivated(), + 'adminPerm' => \Feedback::hasAdminPerm(Context::getId()), + 'createPerm' => \Feedback::hasCreatePerm(Context::getId()), + ]); } public function index_action(): void diff --git a/app/controllers/course/feedback.php b/app/controllers/course/feedback.php index dec20009bbbab084cbe3c35ffb241717f4bc5bb9..c6eb9771be60c71dee66a7d196905d06b75f90d0 100644 --- a/app/controllers/course/feedback.php +++ b/app/controllers/course/feedback.php @@ -49,7 +49,7 @@ class Course_FeedbackController extends AuthenticatedController $widget->addLink( _('Neues Feedback-Element'), $this->url_for('course/feedback/create_form'), - Icon::create('star') + Icon::create('add') )->asDialog(); } } @@ -72,6 +72,8 @@ class Course_FeedbackController extends AuthenticatedController 'results_visible' => 1, 'commentable' => 1, 'mode' => FeedbackElement::MODE_5STAR_RATING, + 'mode' => 1, + 'anonymous_entries' => 1, ]); } @@ -99,6 +101,7 @@ class Course_FeedbackController extends AuthenticatedController 'description' => Studip\Markup::purifyHtml(Request::get('description')), 'results_visible' => intval(Request::get('results_visible')), 'commentable' => $commentable, + 'anonymous_entries' => intval(Request::get('anonymous_entries')), 'mode' => $mode ]); $feedback->store(); @@ -232,11 +235,13 @@ class Course_FeedbackController extends AuthenticatedController if ($rating == 0) { $rating = 1; } + $anonymous = intval(Request::get('anonymous')); $entry = FeedbackEntry::build([ 'feedback_id' => $this->feedback->id, 'user_id' => $GLOBALS['user']->id, 'rating' => $rating, - 'comment' => trim(Request::get('comment')) + 'comment' => trim(Request::get('comment')), + 'anonymous' => $anonymous, ]); $entry->store(); PageLayout::postSuccess(_('Feedback gespeichert')); @@ -268,6 +273,7 @@ class Course_FeedbackController extends AuthenticatedController } $entry->comment = trim(Request::get('comment')); $entry->rating = $rating; + $entry->anonymous = Request::int('anonymous', 0); $entry->store(); PageLayout::postSuccess(_('Änderungen gespeichert')); $this->redirect($entry->feedback->getRange()->getRangeUrl()); diff --git a/app/views/course/courseware/courseware.php b/app/views/course/courseware/courseware.php index 2dadc86a667ec66b19d73b344841cede8d019628..9de924d18f190db7adcdae636fcf3b352b82dcd5 100644 --- a/app/views/course/courseware/courseware.php +++ b/app/views/course/courseware/courseware.php @@ -6,6 +6,7 @@ entry-id="<?= htmlReady(Context::getId()) ?>" unit-id="<?= htmlReady($unit_id) ?>" licenses='<?= htmlReady($licenses) ?>' + feedback-settings='<?= htmlReady($feedback_settings) ?>' > </div> <? endif; ?> diff --git a/app/views/course/courseware/index.php b/app/views/course/courseware/index.php index 81296cbb149d1cd1ba7fd01baa2ec1acfe645158..eea40635c425b26ded94d3d63c1a93cf3488a3fc 100644 --- a/app/views/course/courseware/index.php +++ b/app/views/course/courseware/index.php @@ -3,4 +3,5 @@ entry-type="courses" entry-id="<?= Context::getId() ?>" licenses='<?= $licenses ?>' + feedback-settings='<?= htmlReady($feedback_settings) ?>' ></div> diff --git a/app/views/course/feedback/_add_edit_entry_form.php b/app/views/course/feedback/_add_edit_entry_form.php index b117b782e9db296c3fff8e064da4c01a5b56de59..1481f57fb747054ca10ce20acead54ed5f777fe3 100644 --- a/app/views/course/feedback/_add_edit_entry_form.php +++ b/app/views/course/feedback/_add_edit_entry_form.php @@ -29,6 +29,12 @@ <textarea name="comment"><?= htmlReady(isset($entry) ? $entry->comment : '') ?></textarea> </label> <? endif; ?> +<? if ($feedback->anonymous_entries) : ?> +<label> + <input type="checkbox" name="anonymous" value="1" <?= $entry->anonymous ? 'checked' : '' ?> > + <?= _('Kommentar anonym abgeben') ?> +</label> +<? endif; ?> <div> <?= Studip\Button::createAccept(_('Absenden'), 'add', ['class' => 'feedback-entry-submit']) ?> <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel', ['class' => 'feedback-entry-cancel']) ?> diff --git a/app/views/course/feedback/_entry.php b/app/views/course/feedback/_entry.php index 29f35fbd967c8a938c311596cf8c6b258222138d..b7473d120451ca5ba9ae2c22a42b699f82e008b7 100644 --- a/app/views/course/feedback/_entry.php +++ b/app/views/course/feedback/_entry.php @@ -1,10 +1,15 @@ <article class="studip feedback-entry" data-id="<?= $entry->id ?>"> <header> <h1> + <? if (!$entry->anonymous): ?> <a href="<?= URLHelper::getLink('dispatch.php/profile?username=' . $entry->user->username) ?>"> <?= Avatar::getAvatar($entry->user_id)->getImageTag(Avatar::SMALL) ?> <?= htmlReady($entry->user->getFullName()) ?> </a> + <? else: ?> + <?= Avatar::getNobody()->getImageTag(Avatar::SMALL) ?> + <?= _('Anonym') ?> + <? endif; ?> </h1> <nav> <? if ($entry->isEditable()) : ?> diff --git a/app/views/course/feedback/_new_edit_feedback_form.php b/app/views/course/feedback/_new_edit_feedback_form.php index 224cd11e3f25cdc028f0e6a2e438078f81f5bd55..df6b8212aab16f08f74bce69a9a35c2e5367607d 100644 --- a/app/views/course/feedback/_new_edit_feedback_form.php +++ b/app/views/course/feedback/_new_edit_feedback_form.php @@ -14,7 +14,17 @@ </label> <label> <input type="checkbox" name="results_visible" value="1" <?= $feedback->results_visible == 1 ? 'checked' : '' ?>> - <?= _('Feedback Ergebnisse nach Antwort sichtbar') ?> + <?= _('Feedback-Ergebnisse nach Antwort sichtbar') ?> + </label> + <label> + <input + type="checkbox" + name="anonymous_entries" + value="1" + <?= $this->current_action === 'edit_form' ? 'disabled' : '' ?> + <?= $feedback->anonymous_entries ? 'checked' : '' ?> + > + <?= _('Feedback kann anonym abgegeben werden')?> </label> <label> <input id="comment-activated" type="checkbox" name="commentable" value="1" <? if ($this->current_action == diff --git a/app/views/course/feedback/index.php b/app/views/course/feedback/index.php index 602d445a33f62aaca82b4c570bca2f18195d3b31..87a2449fc867cee3d6071b324197a0a68f8a2fdc 100644 --- a/app/views/course/feedback/index.php +++ b/app/views/course/feedback/index.php @@ -102,13 +102,13 @@ $actionMenu = ActionMenu::get()->setContext($feedback->question); $actionMenu->addLink( $controller->link_for('course/feedback/edit_form/' . $feedback->id), - _('Feedback-Element bearbeiten'), + _('Bearbeiten'), Icon::create('edit', Icon::ROLE_CLICKABLE, ['size' => 20]), ['data-dialog' => ''] ); $actionMenu->addLink( $controller->link_for('course/feedback/delete/' . $feedback->id), - _('Feedback-Element löschen'), + _('Löschen'), Icon::create('trash', Icon::ROLE_CLICKABLE, ['size' => 20]), ['onclick' => "return STUDIP.Dialog.confirmAsPost('" . _('Feedback-Element und dazugehörige Einträge löschen?') . "', this.href);"] ); diff --git a/db/migrations/5.5.22_add_feedback_anonymous_entries.php b/db/migrations/5.5.22_add_feedback_anonymous_entries.php new file mode 100644 index 0000000000000000000000000000000000000000..2e557b532e79c1c28d611aeb64d41005c97428a3 --- /dev/null +++ b/db/migrations/5.5.22_add_feedback_anonymous_entries.php @@ -0,0 +1,27 @@ +<?php +final class AddFeedbackAnonymousEntries extends Migration +{ + public function description() + { + return 'Extend feedback tables for anonymous entries'; + } + + public function up() + { + \DBManager::get()->exec("ALTER TABLE `feedback` + ADD `anonymous_entries` TINYINT(1) NOT NULL DEFAULT 0 + AFTER `commentable` + "); + + \DBManager::get()->exec("ALTER TABLE `feedback_entries` + ADD `anonymous` TINYINT(1) NOT NULL DEFAULT 0 + AFTER `rating` + "); + } + + public function down() + { + \DBManager::get()->exec("ALTER TABLE `feedback` DROP `anonymous_entries`"); + \DBManager::get()->exec("ALTER TABLE `feedback_entries` DROP `anonymous`"); + } +} diff --git a/lib/classes/FeedbackRange.interface.php b/lib/classes/FeedbackRange.interface.php index f5eaaeff743f622619105796e93d2ad858eadb61..863c197ba64f4424e8be500144c7ac74948b851c 100644 --- a/lib/classes/FeedbackRange.interface.php +++ b/lib/classes/FeedbackRange.interface.php @@ -11,6 +11,13 @@ interface FeedbackRange { + /** + * Returns the ID of this range. + * + * @return string|integer The ID of the range. + */ + public function getId(); + /** * Returns a human-friendly representation of the FeedbackRange object instance's name. * diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index e86617bcfbffc42feb740ab97343ebef2acd783b..d4d5bbb21210e48392e1f8f75e08ce8025a4f4f8 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -239,12 +239,20 @@ class RouteMap private function addAuthenticatedFeedbackRoutes(RouteCollectorProxy $group): void { $group->get('/feedback-elements/{id}', Routes\Feedback\FeedbackElementsShow::class); - $group->get('/feedback-elements/{id}/entries', Routes\Feedback\FeedbackEntriesIndex::class); $group->get('/courses/{id}/feedback-elements', Routes\Feedback\FeedbackElementsByCourseIndex::class); $group->get('/file-refs/{id}/feedback-elements', Routes\Feedback\FeedbackElementsByFileRefIndex::class); $group->get('/folders/{id}/feedback-elements', Routes\Feedback\FeedbackElementsByFolderIndex::class); + $group->post('/feedback-elements', Routes\Feedback\FeedbackElementsCreate::class); + $group->patch('/feedback-elements/{id}', Routes\Feedback\FeedbackElementsUpdate::class); + $group->delete('/feedback-elements/{id}', Routes\Feedback\FeedbackElementsDelete::class); + + $group->get('/feedback-elements/{id}/entries', Routes\Feedback\FeedbackEntriesIndex::class); + $group->post('/feedback-entries', Routes\Feedback\FeedbackEntriesCreate::class); + $group->get('/feedback-entries/{id}', Routes\Feedback\FeedbackEntriesShow::class); + $group->patch('/feedback-entries/{id}', Routes\Feedback\FeedbackEntriesUpdate::class); + $group->delete('/feedback-entries/{id}', Routes\Feedback\FeedbackEntriesDelete::class); } private function addAuthenticatedInstitutesRoutes(RouteCollectorProxy $group): void diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php b/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php index dde67bcb18eb12e55ac84432fe61e7ac1ef83421..b09d0c8b5c11228c10730504dfb80cf64afd22d0 100644 --- a/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php +++ b/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php @@ -19,6 +19,7 @@ class CoursesUnitsIndex extends JsonApiController protected $allowedIncludePaths = [ 'structural-element', 'creator', + 'feedback-element', ]; protected $allowedPagingParameters = ['offset', 'limit']; diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php index 8bb01968b7f21ac70a36ba9f5997e3be22ee1001..5f0269089541d36935bf067c3f3ee339fca8ff18 100644 --- a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php +++ b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php @@ -67,10 +67,12 @@ class CoursewareInstancesUpdate extends JsonApiController return 'Attribute `favorite-block-types` contains an invalid block type.'; } } - } elseif (self::arrayHas($json, 'data.attributes.sequential-progression')) { + } + + if (self::arrayHas($json, 'data.attributes.sequential-progression')) { $sequentialProgression = self::arrayGet($json, 'data.attributes.sequential-progression'); - if (!is_bool($sequentialProgression)) { - return 'Attribute `sequential-progression` must be a bool.'; + if (!in_array($sequentialProgression, [0, 1])) { + return 'Attribute `sequential-progression` must be 0 or 1.'; } } @@ -94,6 +96,20 @@ class CoursewareInstancesUpdate extends JsonApiController } } + if (self::arrayHas($json, 'data.attributes.show-feedback-popup')) { + $showFeedbackPopup = self::arrayGet($json, 'data.attributes.show-feedback-popup'); + if (!in_array($showFeedbackPopup, [0,1])) { + return 'Attribute `show-feedback-popup` must be 0 or 1.'; + } + } + + if (self::arrayHas($json, 'data.attributes.show-feedback-in-contentbar')) { + $showFeedbackInContentbar = self::arrayGet($json, 'data.attributes.show-feedback-in-contentbar'); + if (!in_array($showFeedbackInContentbar, [0,1])) { + return 'Attribute `show-feedback-in-contentbar` must be 0 or 1.'; + } + } + if (self::arrayHas($json, 'data.attributes.certificate-settings')) { $certificateSettings = self::arrayGet($json, 'data.attributes.certificate-settings'); @@ -137,6 +153,12 @@ class CoursewareInstancesUpdate extends JsonApiController $editingPermissionLevel = $get('data.attributes.editing-permission-level'); $instance->setEditingPermissionLevel($editingPermissionLevel); + $showFeedbackPopup = $get('data.attributes.show-feedback-popup'); + $instance->setShowFeedbackPopup($showFeedbackPopup); + + $showFeedbackInContentbar = $get('data.attributes.show-feedback-in-contentbar'); + $instance->setShowFeedbackInContentbar($showFeedbackInContentbar); + $certificateSettings = $get('data.attributes.certificate-settings'); $instance->setCertificateSettings($certificateSettings); diff --git a/lib/classes/JsonApi/Routes/Feedback/Authority.php b/lib/classes/JsonApi/Routes/Feedback/Authority.php index 44397817fa063b867b91ae19a4657dce56b3a28b..5437a0789d103572801b301287d9ea31ec6d648d 100644 --- a/lib/classes/JsonApi/Routes/Feedback/Authority.php +++ b/lib/classes/JsonApi/Routes/Feedback/Authority.php @@ -2,45 +2,88 @@ namespace JsonApi\Routes\Feedback; +use Feedback; +use FeedbackElement; +use FeedbackEntry; +use FeedbackRange; +use SimpleORMap; use User; +/** + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.TooManyPublicMethods) + */ class Authority { - public static function canShowFeedbackElement(User $user, \FeedbackElement $resource) + public static function canShowFeedbackElement(User $user, FeedbackElement $resource): bool { - return \Feedback::hasRangeAccess($resource->range_id, $resource->range_type, $user->id); + return Feedback::hasRangeAccess($resource->range_id, $resource->range_type, $user->getId()); } - public static function canIndexFeedbackEntries(User $user, \FeedbackElement $resource) + public static function canIndexFeedbackEntries(User $user, FeedbackElement $resource): bool { return self::canShowFeedbackElement($user, $resource); } - public static function canSeeResultsOfFeedbackElement(User $user, \FeedbackElement $resource) + public static function canSeeResultsOfFeedbackElement(User $user, FeedbackElement $resource): bool { return self::canIndexFeedbackEntries($user, $resource) && - ($resource['results_visible'] || \Feedback::hasAdminPerm($resource['course_id'], $user->id)); + ($resource['results_visible'] || \Feedback::hasAdminPerm($resource['course_id'], $user->getId())); } - public static function canIndexFeedbackElementsOfCourse(User $user, \Course $course) + public static function canIndexFeedbackElementsOfCourse(User $user, \Course $course): bool { - return \Feedback::hasRangeAccess($course->id, \Course::class, $user->id); + return \Feedback::hasRangeAccess($course->getId(), \Course::class, $user->getId()); } - public static function canIndexFeedbackElementsOfFileRef(User $user, \FileRef $fileRef) + public static function canIndexFeedbackElementsOfFileRef(User $user, \FileRef $fileRef): bool { - return \Feedback::hasRangeAccess($fileRef->id, \FileRef::class, $user->id); + return \Feedback::hasRangeAccess($fileRef->getId(), \FileRef::class, $user->getId()); } - public static function canIndexFeedbackElementsOfFolder(User $user, \Folder $folder) + public static function canIndexFeedbackElementsOfFolder(User $user, \Folder $folder): bool { - return \Feedback::hasRangeAccess($folder->id, \Folder::class, $user->id); + return \Feedback::hasRangeAccess($folder->getId(), \Folder::class, $user->getId()); } - public static function canShowFeedbackEntry(User $user, \FeedbackEntry $resource) + public static function canShowFeedbackEntry(User $user, \FeedbackEntry $resource): bool { $feedbackElement = $resource->feedback; return self::canShowFeedbackElement($user, $feedbackElement); } + + public static function canCreateFeedbackEntry(User $user, FeedbackElement $element): bool + { + return $element->isFeedbackable($user->id); + } + + public static function canUpdateFeedbackEntry(User $user, FeedbackEntry $entry): bool + { + return $entry->isEditable($user->id); + } + + public static function canDeleteFeedbackEntry(User $user, FeedbackEntry $entry): bool + { + return $entry->isDeletable($user->id); + } + + public static function canCreateFeedbackElement(User $user, FeedbackRange $range): bool + { + return $range->isRangeAccessible($user->id) + && Feedback::hasCreatePerm($range->getRangeCourseId(), $user->id); + } + + public static function canUpdateFeedbackElement(User $user, FeedbackElement $element): bool + { + $range = $element->getRange(); + + return $range->isRangeAccessible($user->id) + && Feedback::hasAdminPerm($range->getRangeCourseId(), $user->id); + } + + public static function canDeleteFeedbackElement(User $user, FeedbackElement $element): bool + { + return self::canUpdateFeedbackElement($user, $element); + } } diff --git a/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsCreate.php b/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsCreate.php new file mode 100644 index 0000000000000000000000000000000000000000..1269d8c8a273ee999e45f6257a153ddd9533f130 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsCreate.php @@ -0,0 +1,114 @@ +<?php + +namespace JsonApi\Routes\Feedback; + +use FeedbackElement; +use FeedbackRange; +use User; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\FeedbackElement as FeedbackElementSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Create a FeedbackElement. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class FeedbackElementsCreate extends JsonApiController +{ + use RangeTypeAware; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $this->preparePossibleRangeTypes(); + + $json = $this->validate($request); + $range = $this->getRangeFromJson($json); + $user = $this->getUser($request); + + if (!Authority::canCreateFeedbackElement($user, $range)) { + throw new AuthorizationFailedException(); + } + + $feedbackElement = $this->create($user, $json); + + return $this->getCreatedResponse($feedbackElement); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (FeedbackElementSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + $required = ['question', 'description', 'mode', 'results-visible', 'is-commentable', 'anonymous-entries']; + foreach ($required as $attribute) { + if (!self::arrayHas($json, 'data.attributes.' . $attribute)) { + return 'Missing `' . $attribute . '` attribute.'; + } + } + + if (!self::arrayHas($json, 'data.relationships.range')) { + return 'Missing `range` relationship.'; + } + if (!$this->getRangeFromJson($json)) { + return 'Invalid `range` relationship.'; + } + } + + private function getRangeFromJson(array $json): ?FeedbackRange + { + $rangeType = self::arrayGet($json, 'data.relationships.range.data.type'); + $rangeId = self::arrayGet($json, 'data.relationships.range.data.id'); + + if (!isset($this->possibleRangeTypes[$rangeType])) { + return null; + } + $rangeClass = $this->possibleRangeTypes[$rangeType]; + + return $rangeClass::find($rangeId); + } + + private function create(User $user, array $json): FeedbackElement + { + $range = $this->getRangeFromJson($json); + return \FeedbackElement::create([ + 'range_id' => $range->getId(), + 'range_type' => get_class($range), + 'user_id' => $user->id, + 'question' => self::arrayGet($json, 'data.attributes.question'), + 'description' => self::arrayGet($json, 'data.attributes.description'), + 'mode' => self::arrayGet($json, 'data.attributes.mode'), + 'results_visible' => (int) self::arrayGet($json, 'data.attributes.results-visible'), + 'commentable' => (int) self::arrayGet($json, 'data.attributes.is-commentable'), + 'anonymous_entries' => (int) self::arrayGet($json, 'data.attributes.anonymous-entries'), + // TODO: + 'course_id' => $range->getRangeCourseId(), + ]); + } +} diff --git a/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsDelete.php b/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsDelete.php new file mode 100644 index 0000000000000000000000000000000000000000..874a172e7082cdaa337888e57126288409720081 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsDelete.php @@ -0,0 +1,39 @@ +<?php + +namespace JsonApi\Routes\Feedback; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; + +/** + * Deletes a feedback element. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class FeedbackElementsDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = \FeedbackElement::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canDeleteFeedbackElement($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsShow.php b/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsShow.php index 5ecc5932c83f5548d14aabea816251d06e253831..849e58a7ea9f2beed3627b81d3a7e240ed241345 100644 --- a/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsShow.php +++ b/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsShow.php @@ -7,20 +7,33 @@ use Psr\Http\Message\ResponseInterface as Response; use JsonApi\Errors\AuthorizationFailedException; use JsonApi\Errors\RecordNotFoundException; use JsonApi\JsonApiController; +use JsonApi\Schemas\FeedbackElement as FeedbackElementSchema; /** * Displays a certain feedback element. + * + * @SuppressWarnings(PHPMD.StaticAccess) */ class FeedbackElementsShow extends JsonApiController { - protected $allowedIncludePaths = ['author', 'course', 'entries', 'range']; + protected $allowedIncludePaths = [ + FeedbackElementSchema::REL_AUTHOR, + FeedbackElementSchema::REL_COURSE, + FeedbackElementSchema::REL_ENTRIES, + FeedbackElementSchema::REL_RANGE, + ]; /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response */ public function __invoke(Request $request, Response $response, $args) { - if (!$resource = \FeedbackElement::find($args['id'])) { + $resource = \FeedbackElement::find($args['id']); + if (!$resource) { throw new RecordNotFoundException(); } diff --git a/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsUpdate.php b/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..2c02352929551f4ab2092788ccb9fbe1e0194bf2 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Feedback/FeedbackElementsUpdate.php @@ -0,0 +1,94 @@ +<?php + +namespace JsonApi\Routes\Feedback; + +use FeedbackElement; +use FeedbackRange; +use User; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\FeedbackElement as FeedbackElementSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Update a FeedbackElement. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class FeedbackElementsUpdate extends JsonApiController +{ + use RangeTypeAware; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $this->preparePossibleRangeTypes(); + $resource = \FeedbackElement::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + $json = $this->validate($request); + $user = $this->getUser($request); + + if (!Authority::canUpdateFeedbackElement($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $feedbackElement = $this->update($resource, $json); + + return $this->getContentResponse($feedbackElement); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (FeedbackElementSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (!self::arrayHas($json, 'data.id')) { + return 'An existing document must have an `id`.'; + } + + $required = ['question', 'description']; + foreach ($required as $attribute) { + if (!self::arrayHas($json, 'data.attributes.' . $attribute)) { + return 'Missing `' . $attribute . '` attribute.'; + } + } + } + + private function update(FeedbackElement $feedbackElement, array $json): FeedbackElement + { + $strAttrs = ['question', 'description']; + foreach ($strAttrs as $attribute) { + if (self::arrayHas($json, 'data.attributes.' . $attribute)) { + $feedbackElement[$attribute] = self::arrayGet($json, 'data.attributes.' . $attribute); + } + } + + $feedbackElement->store(); + + return $feedbackElement; + } +} diff --git a/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesCreate.php b/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesCreate.php new file mode 100644 index 0000000000000000000000000000000000000000..41efd01eb5a6286e6d52f1d16bf5be957894a83c --- /dev/null +++ b/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesCreate.php @@ -0,0 +1,115 @@ +<?php + +namespace JsonApi\Routes\Feedback; + +use FeedbackElement; +use FeedbackEntry; +use InvalidArgumentException; +use User; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\FeedbackElement as FeedbackElementSchema; +use JsonApi\Schemas\FeedbackEntry as FeedbackEntrySchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Create a FeedbackEntry. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class FeedbackEntriesCreate extends JsonApiController +{ + use RatingHelper; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $element = $this->getElementFromJson($json); + $user = $this->getUser($request); + + if (!Authority::canCreateFeedbackEntry($user, $element)) { + throw new AuthorizationFailedException(); + } + + $feedbackEntry = $this->create($user, $json); + + return $this->getCreatedResponse($feedbackEntry); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (FeedbackEntrySchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + if (!self::arrayHas($json, 'data.relationships.feedback-element')) { + return 'Missing `feedback-element` relationship.'; + } + if (!$this->getElementFromJson($json)) { + return 'Invalid `feedback-element` relationship.'; + } + + $required = ['rating']; + foreach ($required as $attribute) { + if (!self::arrayHas($json, 'data.attributes.' . $attribute)) { + return 'Missing `' . $attribute . '` attribute.'; + } + } + } + + private function getElementFromJson(array $json): ?FeedbackElement + { + $relationship = FeedbackEntrySchema::REL_FEEDBACK; + if (!$this->validateResourceObject($json, 'data.relationships.' . $relationship, FeedbackElementSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.' . $relationship . '.data.id'); + + return FeedbackElement::find($resourceId); + } + + private function create(User $user, array $json): FeedbackEntry + { + $element = $this->getElementFromJson($json); + $entry = \FeedbackEntry::build([ + 'feedback_id' => $element->getId(), + 'user_id' => $user->id, + 'rating' => $this->getRating($element, (int) self::arrayGet($json, 'data.attributes.rating')), + ]); + + if ($element['commentable']) { + $entry['comment'] = self::arrayGet($json, 'data.attributes.comment', ''); + } + if ($element['anonymous_entries']) { + $entry['anonymous'] = (int) self::arrayGet($json, 'data.attributes.anonymous', '0'); + } + + $entry->store(); + + return $entry; + } +} diff --git a/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesDelete.php b/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesDelete.php new file mode 100644 index 0000000000000000000000000000000000000000..7afcdb12ea143ee9316375bab7bb5f2c93f6f182 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesDelete.php @@ -0,0 +1,40 @@ +<?php + +namespace JsonApi\Routes\Feedback; + +use FeedbackEntry; +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; + +/** + * Deletes a feedback entry. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class FeedbackEntriesDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = FeedbackEntry::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canDeleteFeedbackEntry($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesShow.php b/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesShow.php index b591db3e1351e5ea1cf20660d078909686a7e6e4..4a85c69cdbe1a53635f439fb38e787c349ab69d7 100644 --- a/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesShow.php +++ b/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesShow.php @@ -7,19 +7,27 @@ use Psr\Http\Message\ResponseInterface as Response; use JsonApi\Errors\AuthorizationFailedException; use JsonApi\Errors\RecordNotFoundException; use JsonApi\JsonApiController; +use JsonApi\Schemas\FeedbackEntry as FeedbackEntrySchema; /** * Displays a certain feedback entry. */ class FeedbackEntriesShow extends JsonApiController { - protected $allowedIncludePaths = ['author', 'feedback-element']; + protected $allowedIncludePaths = [FeedbackEntrySchema::REL_AUTHOR, FeedbackEntrySchema::REL_FEEDBACK]; + /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.StaticAccess) + * + * @param array $args + * + * @return Response */ public function __invoke(Request $request, Response $response, $args) { - if (!$resource = \FeedbackEntry::find($args['id'])) { + $resource = \FeedbackEntry::find($args['id']); + if (!$resource) { throw new RecordNotFoundException(); } diff --git a/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesUpdate.php b/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..ff64f57e6d01efbf399d87b23d09661aaa9c0e7c --- /dev/null +++ b/lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesUpdate.php @@ -0,0 +1,95 @@ +<?php + +namespace JsonApi\Routes\Feedback; + +use FeedbackElement; +use FeedbackEntry; +use User; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\FeedbackElement as FeedbackElementSchema; +use JsonApi\Schemas\FeedbackEntry as FeedbackEntrySchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Update a FeedbackEntry. + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ +class FeedbackEntriesUpdate extends JsonApiController +{ + use RatingHelper; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = \FeedbackEntry::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + $json = $this->validate($request); + $user = $this->getUser($request); + + if (!Authority::canUpdateFeedbackEntry($user, $resource)) { + throw new AuthorizationFailedException(); + } + + $feedbackEntry = $this->update($resource, $json); + + return $this->getContentResponse($feedbackEntry); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (FeedbackEntrySchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Invalid `type` of document´s `data`.'; + } + if (!self::arrayHas($json, 'data.id')) { + return 'An existing document must have an `id`.'; + } + + $required = ['rating']; + foreach ($required as $attribute) { + if (!self::arrayHas($json, 'data.attributes.' . $attribute)) { + return 'Missing `' . $attribute . '` attribute.'; + } + } + } + + private function update(FeedbackEntry $feedbackEntry, array $json): FeedbackEntry + { + $feedbackEntry->rating = $this->getRating( + $feedbackEntry->feedback, + (int) self::arrayGet($json, 'data.attributes.rating') + ); + if ($feedbackEntry->feedback->commentable && self::arrayHas($json, 'data.attributes.comment')) { + $feedbackEntry->comment = self::arrayGet($json, 'data.attributes.comment'); + } + $feedbackEntry->anonymous = (int) self::arrayGet($json, 'data.attributes.anonymous'); + $feedbackEntry->store(); + + return $feedbackEntry; + } +} diff --git a/lib/classes/JsonApi/Routes/Feedback/RangeTypeAware.php b/lib/classes/JsonApi/Routes/Feedback/RangeTypeAware.php new file mode 100644 index 0000000000000000000000000000000000000000..88fd1b164b6d7f32d15b2b0ba83f62ea01050cfe --- /dev/null +++ b/lib/classes/JsonApi/Routes/Feedback/RangeTypeAware.php @@ -0,0 +1,20 @@ +<?php + +namespace JsonApi\Routes\Feedback; + +use FeedbackRange; +use SimpleORMap; + +trait RangeTypeAware +{ + protected $possibleRangeTypes = null; + + protected function preparePossibleRangeTypes(): void + { + foreach (app('json-api-integration-schemas') as $class => $schema) { + if (is_subclass_of($class, FeedbackRange::class) && is_subclass_of($class, SimpleORMap::class)) { + $this->possibleRangeTypes[$schema::TYPE] = $class; + } + } + } +} diff --git a/lib/classes/JsonApi/Routes/Feedback/RatingHelper.php b/lib/classes/JsonApi/Routes/Feedback/RatingHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..849cba73a7eb33c38b0728a7ab4bcae4e2291a30 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Feedback/RatingHelper.php @@ -0,0 +1,31 @@ +<?php + +namespace JsonApi\Routes\Feedback; + +use FeedbackElement; + +trait RatingHelper +{ + private function getRating(FeedbackElement $element, int $rating): int + { + $mode = intval($element['mode']); + + if ($mode === 0) { + return 0; + } + + if ($rating === 0) { + return 1; + } + + if ($mode === 1) { + return min(5, $rating); + } + + if ($mode === 2) { + return min(10, $rating); + } + + throw new InvalidArgumentException("Invalid mode {$mode}"); + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/Instance.php b/lib/classes/JsonApi/Schemas/Courseware/Instance.php index 7df0cf6d719f1ca5fd33c2fac981bd85234b54d2..114467a61b48143703d4d99009eda4db089f6ed0 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/Instance.php +++ b/lib/classes/JsonApi/Schemas/Courseware/Instance.php @@ -40,6 +40,8 @@ class Instance extends SchemaProvider 'root-layout' => $resource->getRootLayout(), 'sequential-progression' => $resource->getSequentialProgression(), 'editing-permission-level' => $resource->getEditingPermissionLevel(), + 'show-feedback-popup' => $resource->getShowFeedbackPopup(), + 'show-feedback-in-contentbar' => $resource->getShowFeedbackInContentbar(), 'certificate-settings' => $resource->getCertificateSettings(), 'reminder-settings' => $resource->getReminderSettings(), 'reset-progress-settings' => $resource->getResetProgressSettings(), diff --git a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php index ab1dd0f504717f7d8badd459c7225f16bffeb5b4..e6ccafa2f88979c01ee9c586f1e86f837f6b59e1 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php +++ b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php @@ -24,6 +24,7 @@ class StructuralElement extends SchemaProvider const REL_USER = 'user'; const REL_TASK = 'task'; const REL_UNIT = 'unit'; + const REL_FEEDBACKELEMENT = 'feedback-element'; /** * {@inheritdoc} @@ -140,6 +141,12 @@ class StructuralElement extends SchemaProvider $this->shouldInclude($context, self::REL_UNIT) ); + $relationships = $this->addFeedbackElementRelationship( + $relationships, + $resource, + $this->shouldInclude($context, self::REL_FEEDBACKELEMENT) + ); + return $relationships; } @@ -380,6 +387,22 @@ class StructuralElement extends SchemaProvider return $relationships; } + private function addFeedbackElementRelationship(array $relationships, $resource, $includeData): array + { + $relation = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_FEEDBACKELEMENT), + ], + ]; + + $feedback = $resource->getFeedbackElement(); + $relation[self::RELATIONSHIP_DATA] = $feedback; + $relationships[self::REL_FEEDBACKELEMENT] = $relation; + + + return $relationships; + } + private static $memo = []; private function createLinkToCourse($rangeId) diff --git a/lib/classes/JsonApi/Schemas/Courseware/Unit.php b/lib/classes/JsonApi/Schemas/Courseware/Unit.php index 84c6ca21e2de4ca5b5e687b4bf0e10b0e3a89022..901f2f0d882dd86d43b1c511ffc2d76d24cee307 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/Unit.php +++ b/lib/classes/JsonApi/Schemas/Courseware/Unit.php @@ -13,6 +13,7 @@ class Unit extends SchemaProvider const REL_CREATOR= 'creator'; const REL_RANGE = 'range'; const REL_STRUCTURAL_ELEMENT = 'structural-element'; + const REL_FEEDBACK_ELEMENT = 'feedback-element'; /** * {@inheritdoc} @@ -75,6 +76,16 @@ class Unit extends SchemaProvider ] : [self::RELATIONSHIP_DATA => null]; + $feedback = $resource->getFeedbackElement(); + $relationships[self::REL_FEEDBACK_ELEMENT] = $feedback + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($feedback), + ], + self::RELATIONSHIP_DATA => $feedback, + ] + : [self::RELATIONSHIP_DATA => null]; + return $relationships; } } diff --git a/lib/classes/JsonApi/Schemas/FeedbackElement.php b/lib/classes/JsonApi/Schemas/FeedbackElement.php index 143bb0c1cf47776d38da16e3d6a4a0f26ebf28e3..7b9e8cb372d5f388f2ed24817f4fd9e01fe6642d 100644 --- a/lib/classes/JsonApi/Schemas/FeedbackElement.php +++ b/lib/classes/JsonApi/Schemas/FeedbackElement.php @@ -2,24 +2,28 @@ namespace JsonApi\Schemas; +use JsonApi\Errors\InternalServerError; use Neomerx\JsonApi\Contracts\Schema\ContextInterface; use Neomerx\JsonApi\Schema\Link; class FeedbackElement extends SchemaProvider { - const TYPE = 'feedback-elements'; - const REL_AUTHOR = 'author'; - const REL_COURSE = 'course'; - const REL_ENTRIES = 'entries'; - const REL_RANGE = 'range'; + public const TYPE = 'feedback-elements'; + public const REL_AUTHOR = 'author'; + public const REL_COURSE = 'course'; + public const REL_ENTRIES = 'entries'; + public const REL_RANGE = 'range'; public function getId($resource): ?string { - return (int) $resource->id; + return (string) $resource->id; } + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ public function getAttributes($resource, ContextInterface $context): iterable { $attributes = [ @@ -28,6 +32,9 @@ class FeedbackElement extends SchemaProvider 'mode' => (int) $resource['mode'], 'results-visible' => (bool) $resource['results_visible'], 'is-commentable' => (bool) $resource['commentable'], + 'anonymous-entries' => (bool) $resource['anonymous_entries'], + 'average-rating' => $resource->getAverageRating(), + 'has-entries' => $resource->hasEntries(), 'mkdate' => date('c', $resource['mkdate']), 'chdate' => date('c', $resource['chdate']) @@ -76,7 +83,7 @@ class FeedbackElement extends SchemaProvider return $relationships; } - private function getAuthorRelationship(array $relationships, \FeedbackElement $resource, $includeData): array + private function getAuthorRelationship(array $relationships, \FeedbackElement $resource, bool $includeData): array { $userId = $resource['user_id']; $related = $includeData ? \User::find($userId) : \User::build(['id' => $userId], false); @@ -90,7 +97,7 @@ class FeedbackElement extends SchemaProvider return $relationships; } - private function getCourseRelationship(array $relationships, \FeedbackElement $resource, $includeData): array + private function getCourseRelationship(array $relationships, \FeedbackElement $resource, bool $includeData): array { if ($courseId = $resource['course_id']) { $related = $includeData ? \Course::find($courseId) : \Course::build(['id' => $courseId], false); @@ -119,25 +126,19 @@ class FeedbackElement extends SchemaProvider private function getRangeRelationship(array $relationships, \FeedbackElement $resource, bool $includeData): array { - $rangeType = $resource['range_type']; - $link = null; - + $range = $resource->getRange(); try { - $link = $this->createLinkToResource($rangeType); - if ( - is_subclass_of($rangeType, \FeedbackRange::class) && - is_subclass_of($rangeType, \SimpleORMap::class) - ) { - if ($range = $rangeType::find($resource['range_id'])) { - $relationships[self::REL_RANGE] = [ - self::RELATIONSHIP_LINKS => [Link::RELATED => $link], - self::RELATIONSHIP_DATA => $range - ]; - } - } + $link = $this->createLinkToResource($range); + $relationships[self::REL_RANGE] = [ + self::RELATIONSHIP_LINKS => [Link::RELATED => $link], + self::RELATIONSHIP_DATA => $range + ]; } catch (\InvalidArgumentException $e) { + // don't show this relation + } catch (InternalServerError $ise) { + // don't show this relation } return $relationships; } -} +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Schemas/FeedbackEntry.php b/lib/classes/JsonApi/Schemas/FeedbackEntry.php index b84bf7709e444f7f0acd7dc857c9e26d1794628b..37e275e614a4ee3ac1dec1753699fc8cf04c657c 100644 --- a/lib/classes/JsonApi/Schemas/FeedbackEntry.php +++ b/lib/classes/JsonApi/Schemas/FeedbackEntry.php @@ -21,6 +21,7 @@ class FeedbackEntry extends SchemaProvider $attributes = [ 'comment' => (string) $resource['comment'], 'rating' => 0 === $resource->feedback->mode ? null : $resource['rating'], + 'anonymous' => (bool) $resource['anonymous'], 'mkdate' => date('c', $resource['mkdate']), 'chdate' => date('c', $resource['chdate']), ]; diff --git a/lib/models/Courseware/Instance.php b/lib/models/Courseware/Instance.php index 1084ed55d6175ba67e75dde68a15b77885108b30..5f6c3436d866bcdda3b34706984e81d26d5dcab3 100644 --- a/lib/models/Courseware/Instance.php +++ b/lib/models/Courseware/Instance.php @@ -174,6 +174,14 @@ class Instance \UserConfig::get($user->id)->store('COURSEWARE_FAVORITE_BLOCK_TYPES', $favorites); } + + + /* + * + * GENERAL SETTINGS + * + */ + /** * Returns which layout is set for root node of this coursware instance * @@ -287,6 +295,43 @@ class Instance } + /* + * + * FEEDBACK + * + */ + + public function getShowFeedbackPopup(): bool + { + $showFeedbackPopup = $this->unit->config['show_feedback_popup'] ?? false; + + return (bool) $showFeedbackPopup; + } + + public function setShowFeedbackPopup(bool $showFeedbackPopup): void + { + $this->unit->config['show_feedback_popup'] = $showFeedbackPopup ? 1 : 0; + } + + public function getShowFeedbackInContentbar(): bool + { + $showFeedbackInContentbar = $this->unit->config['show_feedback__in_contentbar'] ?? false; + + return (bool) $showFeedbackInContentbar; + } + + public function setShowFeedbackInContentbar(bool $showFeedbackInContentbar): void + { + $this->unit->config['show_feedback__in_contentbar'] = $showFeedbackInContentbar ? 1 : 0; + } + + /* + * + * CERTIFICATE + * + */ + + /** * Returns the certificate creation settings. * diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index a63c73f7f9fbf6bdf2a9e2e47d85864be6032e1e..35a0184563bc06284324dd9d537b17cc9d19967e 100644 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -53,7 +53,7 @@ use User; * @property Task $task has_one Task * @property mixed $image additional field */ -class StructuralElement extends \SimpleORMap implements \PrivacyObject +class StructuralElement extends \SimpleORMap implements \PrivacyObject, \FeedbackRange { protected static function configure($config = []) { @@ -151,6 +151,7 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject if (is_a($image, \FileRef::class)) { $image->delete(); } + \FeedbackElement::deleteBySQL('range_id = ? AND range_type = ?', [$this->id, self::class]); } /** @@ -1195,4 +1196,48 @@ SQL; ]); } } + + public function getRangeCourseId(): string + { + return $this->range_id; + } + + public function getRangeName(): string + { + return $this->title; + } + + public function getRangeIcon($role): string + { + return \Icon::create('courseware', $role); + } + + public function getRangeUrl(): string + { + $unit = $this->findUnit(); + + if ($this->range_type === 'user') { + return 'contents/courseware/courseware/' . $unit->id . '#/structural_element/' . $this->id; + } + + return 'course/courseware/courseware/' . $unit->id . '?cid=' . $this->range_id . '#/structural_element/' . $this->id; + } + + public function isRangeAccessible(string $user_id = null): bool + { + $user = \User::find($user_id); + if ($user) { + return $this->canRead($user); + } + + return false; + } + + public function getFeedbackElement() + { + return \FeedbackElement::findOneBySQL( + 'range_id = ? AND range_type = ?', + [$this->id, self::class] + ); + } } diff --git a/lib/models/Courseware/Unit.php b/lib/models/Courseware/Unit.php index bf083281887865b7e9dea4aa43d4ba4518858bd8..2a38a291d40742cd1aa0bcd705a063c8495d7136 100644 --- a/lib/models/Courseware/Unit.php +++ b/lib/models/Courseware/Unit.php @@ -31,7 +31,7 @@ use User; * @property StructuralElement $structural_element has_one StructuralElement */ -class Unit extends \SimpleORMap implements \PrivacyObject +class Unit extends \SimpleORMap implements \PrivacyObject, \FeedbackRange { protected static function configure($config = []) { @@ -60,10 +60,16 @@ class Unit extends \SimpleORMap implements \PrivacyObject ]; $config['registered_callbacks']['after_delete'][] = 'updatePositionsAfterDelete'; + $config['registered_callbacks']['before_delete'][] = 'cbBeforeDelete'; parent::configure($config); } + public function cbBeforeDelete() + { + \FeedbackElement::deleteBySQL('range_id = ? AND range_type = ?', [$this->id, self::class]); + } + public static function findCoursesUnits(\Course $course): array { return self::findBySQL('range_id = ? AND range_type = ?', [$course->id, 'course']); @@ -201,4 +207,46 @@ class Unit extends \SimpleORMap implements \PrivacyObject return $struct; } + + public function getRangeCourseId(): string + { + return $this->range_id; + } + + public function getRangeName(): string + { + return $this->structural_element->title; + } + + public function getRangeIcon($role): string + { + return \Icon::create('content2', $role); + } + + public function getRangeUrl(): string + { + if ($this->structural_element->range_type === 'user') { + return 'contents/courseware/'; + } + + return 'course/courseware/' . '?cid=' . $this->range_id; + } + + public function isRangeAccessible(string $user_id = null): bool + { + $user = \User::find($user_id); + if ($user) { + return $this->canRead($user); + } + + return false; + } + + public function getFeedbackElement() + { + return \FeedbackElement::findOneBySQL( + 'range_id = ? AND range_type = ?', + [$this->id, self::class] + ); + } } diff --git a/lib/models/FeedbackElement.php b/lib/models/FeedbackElement.php index 63a2186a04659ade881dd5ec4dabca6415234f76..468f146e60be0221da7ac08337cb7d06e367098b 100644 --- a/lib/models/FeedbackElement.php +++ b/lib/models/FeedbackElement.php @@ -3,6 +3,7 @@ /** * * @author Nils Gehrke <nils.gehrke@uni-goettingen.de> + * @author Ron Lucke <lucke@elan-ev.de> * * The column "range_type" represents the name of a class that implements * FeedbackRange. @@ -17,6 +18,7 @@ * @property int $mode database column * @property int $results_visible database column * @property int $commentable database column + * @property int $anonymous_entries database column * @property int $mkdate database column * @property int $chdate database column * @property SimpleORMapCollection|FeedbackEntry[] $entries has_many FeedbackEntry @@ -156,6 +158,22 @@ class FeedbackElement extends SimpleORMap } } + public function getAverageRating(): float + { + $ratings = $this->getRatings(); + + if (empty($ratings)) { + return 0; + } + + return array_sum($ratings) / count($ratings); + } + + public function hasEntries(): bool + { + return count($this->getRatings()) > 0; + } + public function getRange() { return $this->range_type::find($this->range_id); diff --git a/lib/models/FeedbackEntry.php b/lib/models/FeedbackEntry.php index 4b4ea7c35642fc0984ad680336571a70134aa88c..9c6ab4178fae8aab66b155982208b3eade312cb9 100644 --- a/lib/models/FeedbackEntry.php +++ b/lib/models/FeedbackEntry.php @@ -3,12 +3,14 @@ /** * * @author Nils Gehrke <nils.gehrke@uni-goettingen.de> + * @author Ron Lucke <lucke@elan-ev.de> * * @property int $id database column * @property int $feedback_id database column * @property string $user_id database column * @property string $comment database column * @property int $rating database column + * @property int $anonymous database column * @property int $mkdate database column * @property int $chdate database column * @property FeedbackElement $feedback belongs_to FeedbackElement diff --git a/lib/modules/CoursewareModule.class.php b/lib/modules/CoursewareModule.class.php index 6766f8ce387f4aa4cb0230e1b767c998240c588b..8deeda769686fcaed3819ecc271ba6dba626dfdb 100644 --- a/lib/modules/CoursewareModule.class.php +++ b/lib/modules/CoursewareModule.class.php @@ -73,7 +73,7 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule ); $navigation->addSubNavigation( 'comments', - new Navigation(_('Kommentare und Feedback'), 'dispatch.php/course/courseware/comments_overview?cid=' . $courseId) + new Navigation(_('Kommentare und Anmerkungen'), 'dispatch.php/course/courseware/comments_overview?cid=' . $courseId) ); return ['courseware' => $navigation]; diff --git a/public/assets/images/icons/black/feedback.svg b/public/assets/images/icons/black/feedback.svg new file mode 100644 index 0000000000000000000000000000000000000000..00ab1a46020c81bd8a7086f4cfe231a841c0e7d0 --- /dev/null +++ b/public/assets/images/icons/black/feedback.svg @@ -0,0 +1 @@ +<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M37.35 29.74 46 23.45H35.3L32 13.26l-3.3 10.19H18l8.65 6.29-3.3 10.19 8.65-6.3 8.65 6.3-3.3-10.19z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/feedback.svg b/public/assets/images/icons/blue/feedback.svg new file mode 100644 index 0000000000000000000000000000000000000000..11c43a4accff1b03cf32b6640687a51c690384f4 --- /dev/null +++ b/public/assets/images/icons/blue/feedback.svg @@ -0,0 +1 @@ +<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#28497c"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M37.35 29.74 46 23.45H35.3L32 13.26l-3.3 10.19H18l8.65 6.29-3.3 10.19 8.65-6.3 8.65 6.3-3.3-10.19z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/feedback.svg b/public/assets/images/icons/green/feedback.svg new file mode 100644 index 0000000000000000000000000000000000000000..a4e93bce8f05867105faea68fce09aea42a9f062 --- /dev/null +++ b/public/assets/images/icons/green/feedback.svg @@ -0,0 +1 @@ +<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#00962d"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M37.35 29.74 46 23.45H35.3L32 13.26l-3.3 10.19H18l8.65 6.29-3.3 10.19 8.65-6.3 8.65 6.3-3.3-10.19z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/feedback.svg b/public/assets/images/icons/grey/feedback.svg new file mode 100644 index 0000000000000000000000000000000000000000..bc66f121d7df95eef4ee80c9805299c9d35cc7df --- /dev/null +++ b/public/assets/images/icons/grey/feedback.svg @@ -0,0 +1 @@ +<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#6e6e6e"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M37.35 29.74 46 23.45H35.3L32 13.26l-3.3 10.19H18l8.65 6.29-3.3 10.19 8.65-6.3 8.65 6.3-3.3-10.19z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/feedback.svg b/public/assets/images/icons/red/feedback.svg new file mode 100644 index 0000000000000000000000000000000000000000..b927cad894f931a3a9e030c40dc07b7988096f63 --- /dev/null +++ b/public/assets/images/icons/red/feedback.svg @@ -0,0 +1 @@ +<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#cb1800"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M37.35 29.74 46 23.45H35.3L32 13.26l-3.3 10.19H18l8.65 6.29-3.3 10.19 8.65-6.3 8.65 6.3-3.3-10.19z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/feedback.svg b/public/assets/images/icons/white/feedback.svg new file mode 100644 index 0000000000000000000000000000000000000000..cf01a79c8b31b678d4eb15809a72c328c4cb91a7 --- /dev/null +++ b/public/assets/images/icons/white/feedback.svg @@ -0,0 +1 @@ +<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#fff"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M37.35 29.74 46 23.45H35.3L32 13.26l-3.3 10.19H18l8.65 6.29-3.3 10.19 8.65-6.3 8.65 6.3-3.3-10.19z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/feedback.svg b/public/assets/images/icons/yellow/feedback.svg new file mode 100644 index 0000000000000000000000000000000000000000..de8e124c753fe3db955e2e60195ccd5e3d8f8a1c --- /dev/null +++ b/public/assets/images/icons/yellow/feedback.svg @@ -0,0 +1 @@ +<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#ffad00"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M37.35 29.74 46 23.45H35.3L32 13.26l-3.3 10.19H18l8.65 6.29-3.3 10.19 8.65-6.3 8.65 6.3-3.3-10.19z"/></g></svg> \ No newline at end of file diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index d83a23e0313582e7a6ff190402faa468d87a5b63..dba99a776f833ebf0ff04f2c7a64de39c00dc057 100644 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -30,4 +30,4 @@ @import './courseware/layouts/tabs.scss'; @import './courseware/layouts/talk-bubble.scss'; @import './courseware/layouts/tile.scss'; -@import './courseware/layouts/tree.scss'; +@import './courseware/layouts/tree.scss'; \ No newline at end of file diff --git a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss index f05a518c9a6ccac70e73c788b56fbb66a2db804f..7f5b5fdb6bb26a693ce28befc8a5c1e9df696799 100644 --- a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss +++ b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss @@ -129,6 +129,10 @@ $consum_ribbon_width: calc(100% - 58px); vertical-align: text-top; } + .studip-five-stars { + display: inline-block; + } + &.cw-ribbon-breadcrumb-item-current { flex-shrink: 1; } diff --git a/resources/assets/stylesheets/scss/courseware/layouts/tile.scss b/resources/assets/stylesheets/scss/courseware/layouts/tile.scss index 4fd93bda5bdc8fb135a6c74e809c2deda52cec4d..a5632442a3305989bb3bf78630d0fe9605e55021 100644 --- a/resources/assets/stylesheets/scss/courseware/layouts/tile.scss +++ b/resources/assets/stylesheets/scss/courseware/layouts/tile.scss @@ -100,7 +100,7 @@ .progress-wrapper { width: 100%; - padding: 1em 0; + padding: 8px 0; border: none; background: none; @@ -126,10 +126,9 @@ .description-text-wrapper { overflow: hidden; - height: 8em; - margin-top: 0.5em; + height: 10em; + margin-top: 4px; display: -webkit-box; - margin-bottom: 1em; -webkit-line-clamp: 7; -webkit-box-orient: vertical; p { @@ -139,10 +138,14 @@ footer { width: 242px; + margin-top: 8px; color: var(--white); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + display: flex; + align-items: center; + justify-content: space-between; img { vertical-align: text-bottom; diff --git a/resources/assets/stylesheets/scss/feedback.scss b/resources/assets/stylesheets/scss/feedback.scss index a9c34e6000dca283754d366a22dca677a9645aeb..5e11197ea48445e63bc40484eb81bf9985ec532f 100644 --- a/resources/assets/stylesheets/scss/feedback.scss +++ b/resources/assets/stylesheets/scss/feedback.scss @@ -15,7 +15,8 @@ article.studip.feedback-stream { font-weight: normal; white-space: nowrap; } - > img:not(:first-child), > .feedback-star-rating{ + > img:not(:first-child), + > .feedback-star-rating { margin-left: 8px; } } @@ -25,11 +26,13 @@ article.studip.feedback-stream { } .feedback-entry-add { .rating { - label.checked img, label.hover img { + label.checked img, + label.hover img { opacity: 1; } - label img, label.out img { - opacity: .2; + label img, + label.out img { + opacity: 0.2; } label { font-size: 0; @@ -57,7 +60,8 @@ article.studip.feedback-stream { > span { font-weight: bold; } - .avatar-small, span { + .avatar-small, + span { margin-right: 5px; } } @@ -66,7 +70,7 @@ article.studip.feedback-stream { white-space: nowrap; font-size: 0; .inactive { - opacity: .2; + opacity: 0.2; } } .date { @@ -102,3 +106,138 @@ table.feedback { background-color: var(--base-color); min-width: 20px; } + +/* * * * * * * * * * * * +vue feedback components +* * * * * * * * * * * */ + +.five-stars-histogram { + display: flex; + max-width: 420px; + flex-wrap: wrap; + + .five-stars-histogram-average { + padding: 0 2em 0 0; + margin: auto; + text-align: center; + .fraction { + margin: -10px 0; + .average { + font-size: 3em; + font-weight: 700; + margin-bottom: -8px; + } + } + .total { + font-size: 0.8em; + margin-top: -4px; + } + } + .five-stars-histogram-chart { + min-width: 260px; + span { + display: inline-block; + width: 2em; + } + img { + vertical-align: text-bottom; + margin-left: -2px; + } + .percentage { + display: inline-block; + background-color: var(--content-color-10); + width: calc(100% - 6em); + margin: 2px 10px; + .percentage-bar { + background-color: var(--yellow); + color: transparent; + min-width: 0px; + padding: 0; + margin: 0; + } + } + } + + &.vertical { + width: 260px; + height: 230px; + margin-bottom: 1em; + .five-stars-histogram-average { + padding: 0; + } + } +} + +.five-stars-input { + margin: 8px auto; + + button { + border: none; + background: transparent; + padding: 0 14px; + cursor: pointer; + } +} + +.feedback-dialog { + display: flex; + flex-wrap: wrap; + + .feedback-dialog-content { + width: 540px; + padding-left: 3em; + h2 { + display: inline-block; + width: calc(100% - 40px); + margin: 0; + } + ul { + list-style: none; + padding: 0; + } + .feedback-dialog-content-header { + border-bottom: solid thin var(--content-color-40); + padding-bottom: 4px; + } + } +} + +.feedback-element-update, +.feedback-entry-create { + background-color: var(--content-color-10); + padding: 1em; + margin: 8px 0 16px 0; + + h3 { + margin: 0 0 1em 0; + } + textarea { + width: calc(100% - 8px); + height: 6em; + resize: none; + } + .button-wrapper { + display: flex; + flex-direction: row; + justify-content: flex-end; + button.button { + margin: 8px 0 0 5px; + } + } +} + +.feedback-entry-box { + display: flex; + margin-bottom: 1em; + padding: 8px; + border: solid thin var(--content-color-40); + .feedback-entry-box-avatar { + margin-right: 1em; + } + .feedback-entry-box-content { + flex-grow: 1; + h4 { + margin: 0 0 2px 0; + } + } +} \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue b/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue index 32ba86e6318c4aa8cc9f9e3a9b620880e682712d..d3fd69c376002214aadb5d25e791ecbcab184bf3 100644 --- a/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue +++ b/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue @@ -27,7 +27,7 @@ <a href="#">{{ $gettext('Kommentare') }}</a> </th> <th class="responsive-hidden" :class="getSortClass('feedback')" @click="sort('feedback')"> - <a href="#">{{ $gettext('Feedback') }}</a> + <a href="#">{{ $gettext('Anmerkungen') }}</a> </th> <th class="actions"> {{ $gettext('Aktionen') }} @@ -60,7 +60,7 @@ @click.prevent="enableFeedbackDialog(block)" > {{ $gettextInterpolate( - $ngettext('%{length} Feedback', '%{length} Feedbacks', block.feedbacks.length), + $ngettext('%{length} Anmerkung', '%{length} Anmerkungen', block.feedbacks.length), {length: block.feedbacks.length} ) }} </a> @@ -81,7 +81,7 @@ <tbody v-else> <tr class="empty"> <td colspan="6"> - {{ $gettext('Es wurden keine Kommentare oder Feedback gefunden') }} + {{ $gettext('Es wurden keine Kommentare oder Anmerkungen gefunden') }} </td> </tr> </tbody> @@ -204,7 +204,7 @@ export default { let menuItems = []; menuItems.push({ id: 1, label: this.$gettext('Kommentare anzeigen'), icon: 'comment2', emit: 'showComments' }); if (block.element.attributes['can-edit']) { - menuItems.push({ id: 2, label: this.$gettext('Feedback anzeigen'), icon: 'comment2', emit: 'showFeedback' }); + menuItems.push({ id: 2, label: this.$gettext('Anmerkungen anzeigen'), icon: 'comment2', emit: 'showFeedback' }); } return menuItems; diff --git a/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue b/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue index 825ee8fb3cc29df63b5c002dc74192ec9451a806..d157f5b42b96b68e94cddfb7bdb5675c2b92c2a1 100644 --- a/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue +++ b/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue @@ -75,7 +75,7 @@ export default { return this.$gettext('Kommentare'); } if (this.isFeedback) { - return this.$gettext('Feedback'); + return this.$gettext('Anmerkungen'); } return ''; diff --git a/resources/vue/components/courseware/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/CoursewareDashboardStudents.vue index ff59669729a601d3373bf5705a56475595de1cc1..bac31a6f0fc93541a1e3d9d55a815bc5c3b2b2f3 100644 --- a/resources/vue/components/courseware/CoursewareDashboardStudents.vue +++ b/resources/vue/components/courseware/CoursewareDashboardStudents.vue @@ -24,7 +24,7 @@ </th> <th>{{ $gettext('Abgabe') }}</th> <th class="responsive-hidden renewal">{{ $gettext('Verlängerungsanfrage') }}</th> - <th class="responsive-hidden feedback">{{ $gettext('Feedback') }}</th> + <th class="responsive-hidden feedback">{{ $gettext('Anmerkungen') }}</th> </tr> </thead> <tbody> @@ -106,15 +106,15 @@ <span v-if="feedback" :title=" - $gettext('Feedback geschrieben am:') + + $gettext('Anmerkung geschrieben am:') + ' ' + getReadableDate(feedback.attributes['chdate']) " > <studip-icon shape="accept" role="status-green" /> - {{ $gettext('Feedback gegeben') }} + {{ $gettext('Anmerkung gegeben') }} <studip-icon - :title="$gettext('Feedback bearbeiten')" + :title="$gettext('Anmerkung bearbeiten')" class="edit" shape="edit" role="clickable" @@ -127,7 +127,7 @@ class="button" @click="addFeedback(task)" > - {{ $gettext('Feedback geben') }} + {{ $gettext('Anmerkung geben') }} </button> </td> </tr> @@ -193,12 +193,12 @@ v-if="currentDialogFeedback.attributes.content === ''" mood="pointing" :msgCompanion=" - $gettext('Sie haben kein Feedback geschrieben, beim Speichern wird dieses Feedback gelöscht!') + $gettext('Sie haben keine Anmerkungen geschrieben, beim Speichern wird diese Anmerkung gelöscht!') " /> <form class="default" @submit.prevent=""> <label> - {{ $gettext('Feedback') }} + {{ $gettext('Anmerkung') }} <textarea v-model="currentDialogFeedback.attributes.content" /> </label> </form> @@ -220,7 +220,7 @@ <template v-slot:dialogContent> <form class="default" @submit.prevent=""> <label> - {{ $gettext('Feedback') }} + {{ $gettext('Anmerkung') }} <textarea v-model="currentDialogFeedback.attributes.content" /> </label> </form> @@ -264,12 +264,12 @@ export default { close: this.$gettext('Schließen'), }, editFeedbackDialog: { - title: this.$gettext('Feedback zur Aufgabe ändern'), + title: this.$gettext('Anmerkung zur Aufgabe ändern'), confirm: this.$gettext('Speichern'), close: this.$gettext('Schließen'), }, addFeedbackDialog: { - title: this.$gettext('Feedback zur Aufgabe geben'), + title: this.$gettext('Anmerkung zur Aufgabe erstellen'), confirm: this.$gettext('Speichern'), close: this.$gettext('Schließen'), }, @@ -350,7 +350,7 @@ export default { createFeedback() { if (this.currentDialogFeedback.attributes.content === '') { this.companionError({ - info: this.$gettext('Bitte schreiben Sie ein Feedback.'), + info: this.$gettext('Bitte schreiben Sie eine Anmerkung.'), }); return false; } @@ -373,7 +373,7 @@ export default { taskFeedbackId: this.currentDialogFeedback.id, }); this.companionSuccess({ - info: this.$gettext('Feedback wurde gelöscht.'), + info: this.$gettext('Anmerkung wurde gelöscht.'), }); } else { await this.updateTaskFeedback({ @@ -381,7 +381,7 @@ export default { taskFeedbackId: this.currentDialogFeedback.id, }); this.companionSuccess({ - info: this.$gettext('Feedback wurde gespeichert.'), + info: this.$gettext('Anmerkung wurde gespeichert.'), }); } diff --git a/resources/vue/components/courseware/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/CoursewareDashboardTasks.vue index caaea0bb1b6f654782b34cc392a2bd95e2d0649c..6de9c13882b6bdf3aa1a39c84cd65df2c65a4487 100644 --- a/resources/vue/components/courseware/CoursewareDashboardTasks.vue +++ b/resources/vue/components/courseware/CoursewareDashboardTasks.vue @@ -12,7 +12,7 @@ <th>{{ $gettext('Abgabefrist') }}</th> <th>{{ $gettext('Abgabe') }}</th> <th class="responsive-hidden">{{ $gettext('Verlängerungsanfrage') }}</th> - <th class="responsive-hidden">{{ $gettext('Feedback') }}</th> + <th class="responsive-hidden">{{ $gettext('Anmerkung') }}</th> <th class="actions">{{ $gettext('Aktionen') }}</th> </tr> </thead> @@ -57,7 +57,7 @@ <td class="responsive-hidden"> <studip-icon v-if="feedback" - :title="$gettext('Feedback anzeigen')" + :title="$gettext('Anmerkung anzeigen')" class="display-feedback" shape="consultation" role="clickable" @@ -126,7 +126,7 @@ export default { currentTaskFeedback: '', text: { feedbackDialog: { - title: this.$gettext('Feedback'), + title: this.$gettext('Anmerkung'), }, submitDialog: { title: this.$gettext('Aufgabe abgeben'), diff --git a/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue b/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue index a5ba72e74321077f74035ea8e9ccc6e63cbed674..f9b6f1a4f89b8b379a97857305859a058fd2e4c3 100644 --- a/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue @@ -25,7 +25,7 @@ <a href="#">{{ $gettext('Kommentare') }}</a> </th> <th class="responsive-hidden" :class="getSortClass('feedback')" @click="sort('feedback')"> - <a href="#">{{ $gettext('Feedback') }}</a> + <a href="#">{{ $gettext('Anmerkungen') }}</a> </th> <th class="actions"> {{ $gettext('Aktionen') }} @@ -57,11 +57,11 @@ <a v-if="element.attributes['can-edit'] && element.feedbacks.length > 0" href="#" - :title="$gettext('Feedback anzeigen')" + :title="$gettext('Anmerkungen anzeigen')" @click.prevent="enableFeedbackDialog(element)" > {{ $gettextInterpolate( - $ngettext('%{length} Feedback', '%{length} Feedbacks', element.feedbacks.length), + $ngettext('%{length} Anmerkung', '%{length} Anmerkungen', element.feedbacks.length), {length: element.feedbacks.length} ) }} </a> @@ -82,7 +82,7 @@ <tbody v-else> <tr class="empty"> <td colspan="6"> - {{ $gettext('Es wurden keine Kommentare oder Feedback gefunden') }} + {{ $gettext('Es wurden keine Kommentare oder Anmerkungen gefunden') }} </td> </tr> </tbody> @@ -198,7 +198,7 @@ export default { let menuItems = []; menuItems.push({ id: 1, label: this.$gettext('Kommentare anzeigen'), icon: 'comment2', emit: 'showComments' }); if (element.attributes['can-edit']) { - menuItems.push({ id: 2, label: this.$gettext('Feedback anzeigen'), icon: 'comment2', emit: 'showFeedback' }); + menuItems.push({ id: 2, label: this.$gettext('Anmerkungen anzeigen'), icon: 'comment2', emit: 'showFeedback' }); } return menuItems; diff --git a/resources/vue/components/courseware/ShelfApp.vue b/resources/vue/components/courseware/ShelfApp.vue index d5877550ec44cdd4c929efa1507e8f63b417ac17..173be71bce4430e7a93f342d314a92d8fd3a9d5e 100644 --- a/resources/vue/components/courseware/ShelfApp.vue +++ b/resources/vue/components/courseware/ShelfApp.vue @@ -41,6 +41,11 @@ export default { CoursewareSharedItems, CoursewareCompanionOverlay, }, + data() { + return { + rate: 0 + } + }, computed: { ...mapGetters({ showUnitAddDialog: 'showUnitAddDialog', diff --git a/resources/vue/components/courseware/blocks/CoursewareBlockFeedback.vue b/resources/vue/components/courseware/blocks/CoursewareBlockFeedback.vue index c13d500de7f905dcfd52ac9ca2628b6c76b92bf1..8bc05c32d3f7ee78ede4226803d7fa916d552b48 100644 --- a/resources/vue/components/courseware/blocks/CoursewareBlockFeedback.vue +++ b/resources/vue/components/courseware/blocks/CoursewareBlockFeedback.vue @@ -16,7 +16,7 @@ </div> <courseware-companion-box v-if="!userIsTeacher && feedback.length === 0" - :msgCompanion="$gettext('Es wurde noch keine Anmerkungen abgegeben.')" + :msgCompanion="$gettext('Es wurde noch keine Anmerkung hinzugefügt.')" mood="pointing" /> <div v-if="userIsTeacher" class="cw-block-feedback-create"> @@ -105,7 +105,7 @@ export default { }); }, async postFeedback() { - this.updateSrMessage(this.$gettext('Feedback gesendet')); + this.updateSrMessage(this.$gettext('Anmerkung gesendet')); const data = { attributes: { feedback: this.feedbackText, diff --git a/resources/vue/components/courseware/structural-element/CoursewareFeedbackPopup.vue b/resources/vue/components/courseware/structural-element/CoursewareFeedbackPopup.vue new file mode 100644 index 0000000000000000000000000000000000000000..687ca3ca7bd2c14f7592a429e6fa835565292b3b --- /dev/null +++ b/resources/vue/components/courseware/structural-element/CoursewareFeedbackPopup.vue @@ -0,0 +1,109 @@ +<template> + <studip-dialog + height="430" + width="600" + :title="$gettext('Feedback')" + :confirmText="$gettext('Feedback abgeben')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + @close="$emit('close')" + @confirm="submitEntry" + > + <template v-slot:dialogContent> + <h2>{{ $gettextInterpolate($gettext('Bewertung für %{title}'), { title: structuralElement.attributes.title }) }}</h2> + + <div class="feedback-entry-create"> + <studip-five-stars-input v-model="rating" /> + <label v-if="isCommentable"> + {{ $gettext('Kommentar') }} + <textarea v-model="comment"></textarea> + </label> + <label v-if="anonymousEntriesEnabled"> + <input type="checkbox" v-model="anonymous" /> + {{ $gettext('Feedback anonym abgeben') }} + </label> + </div> + </template> + </studip-dialog> +</template> +<script> +import StudipFiveStarsInput from '../../feedback/StudipFiveStarsInput.vue'; + +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-feedback-popup', + components: { + StudipFiveStarsInput, + }, + props: { + feedbackElement: { + type: Object, + required: true, + }, + }, + data() { + return { + rating: 0, + comment: '', + anonymous: false + }; + }, + computed: { + ...mapGetters({ + currentUser: 'currentUser', + structuralElementById: 'courseware-structural-elements/byId', + }), + structuralElement() { + return this.structuralElementById({ id: this.feedbackElement.relationships.range.data.id }); + }, + anonymousEntriesEnabled() { + return this.feedbackElement.attributes['anonymous-entries']; + }, + isCommentable() { + return this.feedbackElement.attributes['is-commentable']; + } + }, + methods: { + ...mapActions({ + createFeedbackEntries: 'feedback-entries/create', + }), + submitEntry() { + let data = { + attributes: { + rating: this.rating, + }, + relationships: { + 'feedback-element': { + data: { + type: 'feedback-elements', + id: this.feedbackElement.id, + }, + }, + author: { + data: { + id: this.currentUser.id, + type: 'users', + }, + }, + }, + }; + if (this.isCommentable) { + data.attributes.comment = this.comment + } + if (this.anonymousEntriesEnabled) { + data.attributes.anonymous = this.anonymous; + } + this.createFeedbackEntries(data); + this.$emit('submit'); + }, + }, +}; +</script> +<style scoped> +h2 { + margin-top: 0; + margin-bottom: 20px; +} +</style> diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue index 143a03ae258426433887a66fcd2a0da4a890b6bf..34d3145f5d1f3c44f988c72f1270c83c0a9e817a 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue @@ -52,6 +52,20 @@ ({{ elementProgress }} %) </span> </template> + <studip-five-stars + v-if="showFeedbackInContentbar && hasFeedbackElement" + :amount="hasFeedbackAverage ? feedbackAverage : 5" + :size="16" + :role="hasFeedbackAverage ? 'status-yellow' : 'inactive'" + :title=" + hasFeedbackAverage ? + $gettextInterpolate($gettext('Seite wurde mit %{avg} Sternen bewertet'), { + avg: feedbackAverage, + }) : + $gettext('Seite wurde noch nicht bewertet') + " + @click="menuAction('showFeedback')" + /> </li> </template> <template #breadcrumbFallback> @@ -80,6 +94,7 @@ @activateComments="menuAction('activateComments')" @deactivateComments="menuAction('deactivateComments')" @showFeedback="menuAction('showFeedback')" + @showFeedbackCreate="menuAction('showFeedbackCreate')" /> </template> </courseware-ribbon> @@ -593,6 +608,27 @@ <courseware-structural-element-dialog-export v-if="showExportDialog" :structuralElement="currentElement" /> <courseware-structural-element-dialog-export-pdf v-if="showPdfExportDialog" :structuralElement="currentElement" /> <courseware-structural-element-dialog-add-chooser v-if="showAddChooserDialog" /> + <feedback-dialog + v-if="showFeedbackDialog" + :feedbackElementId="parseInt(feedbackElementId)" + :currentUser="currentUser" + @deleted="loadStructuralElement(currentId)" + @close="showStructuralElementFeedbackDialog(false)" + /> + <feedback-create-dialog + v-if="showFeedbackCreateDialog" + :defaultQuestion="$gettext('Bewerten Sie die Seite')" + rangeType="courseware-structural-elements" + :rangeId="currentElement.id" + @created="loadStructuralElement(currentElement.id)" + @close="showStructuralElementFeedbackCreateDialog(false)" + /> + <courseware-feedback-popup + v-if="showRatingPopup" + :feedbackElement="ratingPopupFeedbackElement" + @close="showRatingPopup = false" + @submit="submitFeedback" + /> </div> <div v-else> <courseware-companion-box @@ -618,6 +654,7 @@ import CoursewareRootContent from './CoursewareRootContent.vue'; import CoursewareStructuralElementComments from './CoursewareStructuralElementComments.vue'; import CoursewareStructuralElementFeedback from './CoursewareStructuralElementFeedback.vue'; +import CoursewareFeedbackPopup from './CoursewareFeedbackPopup.vue'; import CoursewareStructuralElementDialogAdd from './CoursewareStructuralElementDialogAdd.vue'; import CoursewareStructuralElementDialogAddChooser from './CoursewareStructuralElementDialogAddChooser.vue'; import CoursewareStructuralElementDialogCopy from './CoursewareStructuralElementDialogCopy.vue'; @@ -638,6 +675,11 @@ import CoursewareCallToActionBox from '../layouts/CoursewareCallToActionBox.vue' import CoursewareDateInput from '../layouts/CoursewareDateInput.vue'; import StockImageSelector from '../../stock-images/SelectorDialog.vue'; import StudipDialog from '../../StudipDialog.vue'; +import { FocusTrap } from 'focus-trap-vue'; +import IsoDate from '../layouts/IsoDate.vue'; +import FeedbackDialog from '../../feedback/FeedbackDialog.vue' +import FeedbackCreateDialog from '../../feedback/FeedbackCreateDialog.vue'; +import StudipFiveStars from '../../feedback/StudipFiveStars.vue'; import draggable from 'vuedraggable'; import containerMixin from '@/vue/mixins/courseware/container.js'; import { mapActions, mapGetters } from 'vuex'; @@ -662,6 +704,12 @@ export default { CoursewareWelcomeScreen, CoursewareCallToActionBox, CoursewareDateInput, + CoursewareFeedbackPopup, + FeedbackDialog, + FeedbackCreateDialog, + StudipFiveStars, + FocusTrap, + IsoDate, StockImageSelector, StudipDialog, draggable, @@ -729,12 +777,16 @@ export default { showStockImageSelector: false, selectedStockImage: null, displayFeedback: false, + + showRatingPopup: false, + ratingPopupFeedbackElement: null }; }, computed: { ...mapGetters({ courseware: 'courseware', + rootId: 'rootId', context: 'context', consumeMode: 'consumeMode', containerById: 'courseware-containers/byId', @@ -762,6 +814,8 @@ export default { showSuggestOerDialog: 'showSuggestOerDialog', showPublicLinkDialog: 'showStructuralElementPublicLinkDialog', showRemoveLockDialog: 'showStructuralElementRemoveLockDialog', + showFeedbackDialog: 'showStructuralElementFeedbackDialog', + showFeedbackCreateDialog: 'showStructuralElementFeedbackCreateDialog', oerCampusEnabled: 'oerCampusEnabled', oerEnableSuggestions: 'oerEnableSuggestions', licenses: 'licenses', @@ -785,7 +839,13 @@ export default { childrenById: 'courseware-structure/children', rootLayout: 'rootLayout', - toolbarActive: 'toolbarActive' + toolbarActive: 'toolbarActive', + isFeedbackActivated: 'isFeedbackActivated', + canCreateFeedbackElement: 'canCreateFeedbackElement', + getFeedbackElementById: 'feedback-elements/byId', + feedbackEntries: 'feedback-entries/all', + + currentUser: 'currentUser' }), currentId() { @@ -1042,22 +1102,59 @@ export default { return this.editor?.attributes['formatted-name'] ?? '?'; }, + feedbackElementId() { + return this.currentElement?.relationships?.['feedback-element']?.data?.id; + }, + hasFeedbackElement() { + return this.feedbackElementId !== undefined; + }, + showFeedbackInContentbar() { + return this.courseware.attributes['show-feedback-in-contentbar']; + }, + feedbackElement() { + return this.getFeedbackElementById({ id: this.feedbackElementId }); + }, + feedbackAverage() { + return this.feedbackElement?.attributes?.['average-rating'] ?? 0; + }, + hasFeedbackAverage() { + return this.feedbackAverage > 0; + }, + menuItems() { let menu = [ { id: 4, label: this.$gettext('Informationen anzeigen'), icon: 'info', emit: 'showInfo' }, { id: 5, label: this.$gettext('Lesezeichen setzen'), icon: 'star', emit: 'setBookmark' }, ]; + if (this.isFeedbackActivated) { + if (this.canCreateFeedbackElement && !this.hasFeedbackElement) { + menu.push({ + id: 6, + label: this.$gettext('Feedback aktivieren'), + icon: 'feedback', + emit: 'showFeedbackCreate', + }); + } + if (this.hasFeedbackElement) { + menu.push({ + id: 6, + label: this.$gettext('Feedback anzeigen'), + icon: 'feedback', + emit: 'showFeedback', + }); + } + } if (this.oerEnableSuggestions && this.inCourse && this.userId !== this.structuralElement.relationships.owner.data.id) { menu.push( - { id: 6, label: this.$gettext('Seite für OER Campus vorschlagen'), icon: 'oer-campus', + { id: 7, label: this.$gettext('Seite für OER Campus vorschlagen'), icon: 'oer-campus', emit: 'showSuggest' } ); } if (!document.documentElement.classList.contains('responsive-display')) { menu.push( - { id: 7, label: this.$gettext('Als Vollbild anzeigen'), icon: 'screen-full', + { id: 8, label: this.$gettext('Als Vollbild anzeigen'), icon: 'screen-full', emit: 'activateFullscreen'}, ); } @@ -1100,11 +1197,11 @@ export default { menu.push({ id: 3, label: this.$gettext('Seite hinzufügen'), icon: 'add', emit: 'addElement' }); } if (this.context.type === 'users') { - menu.push({ id: 8, label: this.$gettext('Öffentlichen Link erzeugen'), icon: 'group', emit: 'linkElement' }); + menu.push({ id: 9, label: this.$gettext('Öffentlichen Link erzeugen'), icon: 'group', emit: 'linkElement' }); } if (this.deletable && this.canEdit && !this.isTask && !this.blocked) { menu.push({ - id: 8, + id: 10, label: this.$gettext('Seite löschen'), icon: 'trash', emit: 'deleteCurrentElement', @@ -1319,9 +1416,9 @@ export default { companionInfo: 'companionInfo', companionWarning: 'companionWarning', companionError: 'companionError', + companionSuccess: 'companionSuccess', uploadImageForStructuralElement: 'uploadImageForStructuralElement', deleteImageForStructuralElement: 'deleteImageForStructuralElement', - companionSuccess: 'companionSuccess', setStockImageForStructuralElement: 'setStockImageForStructuralElement', showElementEditDialog: 'showElementEditDialog', showElementAddDialog: 'showElementAddDialog', @@ -1334,6 +1431,8 @@ export default { showElementPublicLinkDialog: 'showElementPublicLinkDialog', showElementRemoveLockDialog: 'showElementRemoveLockDialog', updateShowSuggestOerDialog: 'updateShowSuggestOerDialog', + showStructuralElementFeedbackDialog: 'showStructuralElementFeedbackDialog', + showStructuralElementFeedbackCreateDialog: 'showStructuralElementFeedbackCreateDialog', updateContainer: 'updateContainer', createContainer: 'createContainer', sortContainersInStructualElements: 'sortContainersInStructualElements', @@ -1345,6 +1444,8 @@ export default { activateStructuralElementComments: 'activateStructuralElementComments', deactivateStructuralElementComments: 'deactivateStructuralElementComments', loadRelatedFeedback: 'courseware-structural-element-feedback/loadRelated', + createFeedback: 'feedback-elements/create', + loadFeedbackElement: 'feedback-elements/loadById', }), initCurrent() { @@ -1421,7 +1522,10 @@ export default { this.deactivateStructuralElementComments({ element: this.currentElement }); break; case 'showFeedback': - this.displayFeedback = true; + this.showStructuralElementFeedbackDialog(true); + break; + case 'showFeedbackCreate': + this.showStructuralElementFeedbackCreateDialog(true); break; } }, @@ -1771,28 +1875,110 @@ export default { this.showStockImageSelector = false; this.deletingPreviewImage = false; }, + activateFeedback() { + const data = { + attributes: { + question: this.$gettext('Bewerten Sie das Lernmaterial'), + description: '', + mode: 1, + 'results-visible': true, + 'is-commentable': true, + 'anonymous-entries': true, + }, + relationships: { + range: { + data: { + type: 'courseware-structural-elements', + id: this.currentElement.id, + }, + }, + }, + }; + this.createFeedback(data).then(() => { + this.loadStructuralElement(this.currentElement.id); + }); + }, + async showFeedbackPopup(to, from) { + let showRatingPopup = false; + let ratingPopupFeedbackElement = null; + const toId = to.params.id; + const toElem = this.structuralElementById({id: toId}); + if (toId === this.nextElement?.id && toElem.relationships.parent.data.id === this.rootId) { + const firstLevelElement = await this.findFirstLevelParent(this.currentElement); + const feedbackElementId = firstLevelElement?.relationships?.['feedback-element']?.data?.id; + if (feedbackElementId) { + await this.loadFeedbackElement({ id: feedbackElementId, options: { include: 'entries' }}); + ratingPopupFeedbackElement = this.getFeedbackElementById({ id: feedbackElementId }); + const hasUserEntry = this.feedbackEntries.filter( + (entry) => + parseInt(entry.relationships?.['feedback-element']?.data?.id) == feedbackElementId && + this.currentUser.id === entry.relationships?.author?.data?.id + ).length > 0; + + if (this.currentUser.id !== ratingPopupFeedbackElement?.relationships?.author?.data?.id && !hasUserEntry) { + showRatingPopup = true; + } else { + ratingPopupFeedbackElement = null; + } + } + } + this.showRatingPopup = showRatingPopup; + this.ratingPopupFeedbackElement = ratingPopupFeedbackElement; + }, + async findFirstLevelParent(elem) { + const parentId = elem.relationships.parent.data.id; + if (!parentId) { + return null; + } + if (parentId == this.rootId) { + await this.loadStructuralElement(elem.id); + return this.structuralElementById({ id: elem.id }); + } + const parent = this.structuralElementById({ id: parentId }); + + return this.findFirstLevelParent(parent); + }, + submitFeedback() { + this.showRatingPopup = false; + this.companionSuccess({ info: this.$gettext('Feedback wurde abgegeben.') }); + } }, created() { this.pluginManager.registerComponentsLocally(this); }, watch: { - async structuralElement() { - this.setCurrentElementId(this.structuralElement.id); - this.initCurrent(); - if (this.isTask) { - this.loadTask({ - taskId: this.structuralElement.relationships.task.data.id, - }); - } + $route: { + handler(to, from) { + if (this.courseware.attributes['show-feedback-popup']) { + this.showFeedbackPopup(to, from); + } + }, + deep: true + }, + structuralElement: { + async handler() { + this.setCurrentElementId(this.structuralElement.id); + this.initCurrent(); + if (this.isTask) { + this.loadTask({ + taskId: this.structuralElement.relationships.task.data.id, + }); + } - if (this.isLink) { - this.loadStructuralElement(this.structuralElement.attributes['target-id']); - } + if (this.isLink) { + this.loadStructuralElement(this.structuralElement.attributes['target-id']); + } - if (this.inCourse && this.courseware.attributes['sequential-progression'] && !this.userIsTeacher) { - this.loadProgresses(); - } + if (this.inCourse && this.courseware.attributes['sequential-progression'] && !this.userIsTeacher) { + this.loadProgresses(); + } + + if (this.inCourse) { + this.loadFeedbackElement({ id: this.feedbackElementId }); + } + }, + deep: true }, containers() { this.containerList = this.containers; @@ -1818,3 +2004,4 @@ export default { }), }; </script> + diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElementDiscussion.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElementDiscussion.vue index 3428d83af748206eed955e81c1a7c4d2fdd26598..419b8b02d11f05cbe3bc9b8116e6fbdf82c678dc 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElementDiscussion.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElementDiscussion.vue @@ -47,7 +47,7 @@ export default { hasFeedback: false, text: { comments: this.$gettext('Kommentare zur Seite'), - feedback: this.$gettext('Feedback zur Seite') + feedback: this.$gettext('Anmerkungen zur Seite') } } }, diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElementFeedback.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElementFeedback.vue index acecf63044862737aede6a5806f2440fbb180580..8d9f95863d98636ecb59d76af8a28708f6c333f9 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElementFeedback.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElementFeedback.vue @@ -14,10 +14,10 @@ /> </div> <courseware-companion-box - v-if="!userIsTeacher && feedback.length === 0" - :msgCompanion="$gettext('Es wurde noch keine Anmerkungen abgegeben.')" - mood="pointing" - /> + v-if="!userIsTeacher && feedback.length === 0" + :msgCompanion="$gettext('Es wurde noch keine Anmerkung hinzugefügt.')" + mood="pointing" + /> <div v-if="userIsTeacher" class="cw-structural-element-feedback-create"> <textarea v-model="feedbackText" :placeholder="placeHolder" spellcheck="true"></textarea> <button class="button" @click="postFeedback"> @@ -106,7 +106,7 @@ export default { }); }, async postFeedback() { - this.updateSrMessage(this.$gettext('Anmerkung gesendet')); + this.updateSrMessage(this.$gettext('Anmerkung hinzugefügt')); const data = { attributes: { feedback: this.feedbackText, @@ -135,5 +135,12 @@ export default { updated() { this.$refs.feedbacks.scrollTop = this.$refs.feedbacks.scrollHeight; }, + watch: { + feedback() { + if (this.feedback && this.feedback.length > 0) { + this.$emit('hasFeedback'); + } + } + } }; </script> diff --git a/resources/vue/components/courseware/unit/CoursewareShelfDialogTopics.vue b/resources/vue/components/courseware/unit/CoursewareShelfDialogTopics.vue index eb6e88895fc34837251dd8d0a64c008baef1f09d..70745e815807a231311c3ef01755ad3cef835021 100644 --- a/resources/vue/components/courseware/unit/CoursewareShelfDialogTopics.vue +++ b/resources/vue/components/courseware/unit/CoursewareShelfDialogTopics.vue @@ -1,6 +1,6 @@ <template> <studip-dialog - :title="$gettext('Lernmaterial aus Ablaufplan Themen erstellen')" + :title="$gettext('Lernmaterial aus Ablaufplan-Themen erstellen')" :confirmText="$gettext('Erstellen')" confirmClass="accept" :closeText="$gettext('Abbrechen')" diff --git a/resources/vue/components/courseware/unit/CoursewareUnitItem.vue b/resources/vue/components/courseware/unit/CoursewareUnitItem.vue index 6c0ec6e7fbbd7e399c7859afc5ca14a5377dae93..b1e82bfecc0945fdf90818c5228749e3a83360ef 100644 --- a/resources/vue/components/courseware/unit/CoursewareUnitItem.vue +++ b/resources/vue/components/courseware/unit/CoursewareUnitItem.vue @@ -25,22 +25,46 @@ @showSettings="openSettingsDialog" @showLayout="openLayoutDialog" @copyUnit="copy" + @showFeedbackCreate="openFeedbackCreateDialog" + @showFeedback="openFeedbackDialog" /> </template> <template #description> {{ description }} </template> - <template #footer v-if="certificate"> - <studip-icon shape="medal" :size="32" role="info_alt"></studip-icon> + <template #footer> + <template v-if="hasFeedbackElement"> + <studip-five-stars + v-if="hasFeedbackEntries" + :amount="feedbackAverage" + :size="16" + :title=" + $gettextInterpolate($gettext('Lernmaterial wurde mit %{avg} Sternen bewertet'), { + avg: feedbackAverage, + }) + " + /> + <studip-five-stars + v-else + :amount="5" + :size="16" + role="inactive" + :title="$gettext('Lernmaterial wurde noch nicht bewertet')" + /> + </template> + <template v-if="certificate"> + <studip-icon shape="medal" :size="16" role="info_alt" /> + </template> </template> </courseware-tile> <studip-dialog v-if="showDeleteDialog" :title="$gettext('Lernmaterial löschen')" - :question="$gettextInterpolate( - $gettext('Möchten Sie das Lernmaterial %{ unitTitle } wirklich löschen?'), - { unitTitle: title } - )" + :question=" + $gettextInterpolate($gettext('Möchten Sie das Lernmaterial %{ unitTitle } wirklich löschen?'), { + unitTitle: title, + }) + " height="200" @confirm="executeDelete" @close="closeDeleteDialog" @@ -56,13 +80,37 @@ @close="closeProgressDialog" > <template v-slot:dialogContent> - <courseware-unit-progress :progressData="progresses" :unitId="unit.id" :rootId="parseInt(unitElement.id)"/> + <courseware-unit-progress + :progressData="progresses" + :unitId="unit.id" + :rootId="parseInt(unitElement.id)" + /> </template> </studip-dialog> <courseware-unit-item-dialog-export v-if="showExportDialog" :unit="unit" @close="showExportDialog = false" /> - <courseware-unit-item-dialog-settings v-if="showSettingsDialog" :unit="unit" @close="closeSettingsDialog"/> - <courseware-unit-item-dialog-layout v-if="showLayoutDialog" :unit="unit" :unitElement="unitElement" @close="closeLayoutDialog"/> + <courseware-unit-item-dialog-settings v-if="showSettingsDialog" :unit="unit" @close="closeSettingsDialog" /> + <courseware-unit-item-dialog-layout + v-if="showLayoutDialog" + :unit="unit" + :unitElement="unitElement" + @close="closeLayoutDialog" + /> + <feedback-dialog + v-if="showFeedbackDialog" + :feedbackElementId="parseInt(feedbackElementId)" + :currentUser="currentUser" + @deleted="loadUnit({ id: unit.id })" + @close="closeFeedbackDialog" + /> + <feedback-create-dialog + v-if="showFeedbackCreateDialog" + :defaultQuestion="$gettext('Bewerten Sie das Lernmaterial')" + rangeType="courseware-units" + :rangeId="unit.id" + @created="loadUnit({ id: unit.id })" + @close="closeFeedbackCreateDialog" + /> </li> </template> @@ -72,8 +120,12 @@ import CoursewareUnitItemDialogExport from './CoursewareUnitItemDialogExport.vue import CoursewareUnitItemDialogSettings from './CoursewareUnitItemDialogSettings.vue'; import CoursewareUnitItemDialogLayout from './CoursewareUnitItemDialogLayout.vue'; import CoursewareUnitProgress from './CoursewareUnitProgress.vue'; +import FeedbackDialog from '../../feedback/FeedbackDialog.vue'; +import FeedbackCreateDialog from '../../feedback/FeedbackCreateDialog.vue'; +import StudipFiveStars from '../../feedback/StudipFiveStars.vue'; import axios from 'axios'; + import { mapActions, mapGetters } from 'vuex'; export default { @@ -84,6 +136,9 @@ export default { CoursewareUnitItemDialogLayout, CoursewareUnitItemDialogSettings, CoursewareUnitProgress, + FeedbackDialog, + FeedbackCreateDialog, + StudipFiveStars, }, props: { unit: Object, @@ -100,22 +155,49 @@ export default { showProgressDialog: false, showLayoutDialog: false, progresses: null, - certificate: null - } + certificate: null, + showFeedbackDialog: false, + showFeedbackCreateDialog: false, + }; }, computed: { ...mapGetters({ context: 'context', structuralElementById: 'courseware-structural-elements/byId', - userIsTeacher: 'userIsTeacher' + userIsTeacher: 'userIsTeacher', + canCreateFeedbackElement: 'canCreateFeedbackElement', + isFeedbackActivated: 'isFeedbackActivated', + feedbackElementById: 'feedback-elements/byId', + currentUser: 'currentUser', }), menuItems() { let menu = []; if (this.inCourseContext) { menu.push({ id: 1, label: this.$gettext('Fortschritt'), icon: 'progress', emit: 'showProgress' }); + if (this.userIsTeacher) { + menu.push({ id: 2, label: this.$gettext('Einstellungen'), icon: 'settings', emit: 'showSettings' }); + } + if (this.isFeedbackActivated) { + if (this.canCreateFeedbackElement && !this.hasFeedbackElement) { + menu.push({ + id: 6, + label: this.$gettext('Feedback aktivieren'), + icon: 'feedback', + emit: 'showFeedbackCreate', + }); + } + if (this.hasFeedbackElement) { + menu.push({ + id: 6, + label: this.$gettext('Feedback anzeigen'), + icon: 'feedback', + emit: 'showFeedback', + }); + } + } if (this.certificate) { menu.push({ - id: 2, + id: 3, label: this.$gettext('Zertifikat'), icon: 'medal', url: STUDIP.URLHelper.getURL('sendfile.php', { @@ -126,36 +208,54 @@ export default { }); } } - if(this.userIsTeacher && this.inCourseContext) { - menu.push({ id: 2, label: this.$gettext('Einstellungen'), icon: 'settings', emit: 'showSettings' }); - } - if(this.userIsTeacher || !this.inCourseContext) { + + if (this.userIsTeacher || !this.inCourseContext) { menu.push({ id: 4, label: this.$gettext('Darstellung'), icon: 'colorpicker', emit: 'showLayout' }); - menu.push({ id: 4, label: this.$gettext('Duplizieren'), icon: 'copy', emit: 'copyUnit' }); - menu.push({ id: 5, label: this.$gettext('Exportieren'), icon: 'export', emit: 'showExport' }); - menu.push({ id: 6, label: this.$gettext('Löschen'), icon: 'trash', emit: 'showDelete' }); + menu.push({ id: 5, label: this.$gettext('Duplizieren'), icon: 'copy', emit: 'copyUnit' }); + menu.push({ id: 7, label: this.$gettext('Exportieren'), icon: 'export', emit: 'showExport' }); + menu.push({ id: 8, label: this.$gettext('Löschen'), icon: 'trash', emit: 'showDelete' }); } + menu.sort((a, b) => { + return a.id - b.id; + }); return menu; }, unitElement() { - return this.structuralElementById({id: this.unit.relationships['structural-element'].data.id}) ?? null; + return this.structuralElementById({ id: this.unit.relationships['structural-element'].data.id }) ?? null; + }, + feedbackElementId() { + return this.unit.relationships['feedback-element']?.data?.id; + }, + hasFeedbackElement() { + return this.feedbackElementId !== undefined; + }, + hasFeedbackEntries() { + return this.feedbackElement?.attributes?.['has-entries'] ?? false; + }, + feedbackAverage() { + return this.feedbackElement?.attributes?.['average-rating'] ?? 0; + }, + feedbackElement() { + return this.feedbackElementById({ id: this.feedbackElementId }); }, color() { return this.unitElement?.attributes?.payload?.color ?? 'studip-blue'; }, title() { - return this.unitElement?.attributes?.title ?? ''; + return this.unitElement?.attributes?.title ?? ''; }, description() { - return this.unitElement?.attributes?.payload?.description ?? ''; + return this.unitElement?.attributes?.payload?.description ?? ''; }, imageUrl() { return this.unitElement?.relationships?.image?.meta?.['download-url'] ?? ''; }, url() { if (this.inCourseContext) { - return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/courseware/' + this.unit.id , { cid: this.context.id }); + return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/courseware/' + this.unit.id, { + cid: this.context.id, + }); } else { return STUDIP.URLHelper.getURL('dispatch.php/contents/courseware/courseware/' + this.unit.id); } @@ -168,11 +268,11 @@ export default { }, inCourseContext() { return this.context.type === 'courses'; - } + }, }, async mounted() { if (this.inCourseContext) { - this.progresses = await this.loadUnitProgresses({unitId: this.unit.id}); + this.progresses = await this.loadUnitProgresses({ unitId: this.unit.id }); this.checkCertificate(); } }, @@ -180,8 +280,11 @@ export default { ...mapActions({ deleteUnit: 'deleteUnit', loadUnitProgresses: 'loadUnitProgresses', + loadUnit: 'courseware-units/loadById', copyUnit: 'copyUnit', - companionSuccess: 'companionSuccess' + companionSuccess: 'companionSuccess', + createFeedback: 'feedback-elements/create', + loadFeedbackElement: 'feedback-elements/loadById', }), async checkCertificate() { if (this.getStudipConfig('COURSEWARE_CERTIFICATES_ENABLE')) { @@ -193,7 +296,7 @@ export default { } }, executeDelete() { - this.deleteUnit({id: this.unit.id}); + this.deleteUnit({ id: this.unit.id }); }, openDeleteDialog() { this.showDeleteDialog = true; @@ -206,7 +309,7 @@ export default { }, async openProgressDialog() { this.showProgressDialog = true; - this.progresses = await this.loadUnitProgresses({unitId: this.unit.id}); + this.progresses = await this.loadUnitProgresses({ unitId: this.unit.id }); }, closeProgressDialog() { this.showProgressDialog = false; @@ -223,8 +326,23 @@ export default { closeLayoutDialog() { this.showLayoutDialog = false; }, + openFeedbackCreateDialog() { + this.showFeedbackCreateDialog = true; + }, + closeFeedbackCreateDialog() { + this.showFeedbackCreateDialog = false; + }, + openFeedbackDialog() { + if (this.feedbackElementId) { + this.showFeedbackDialog = true; + } + }, + closeFeedbackDialog() { + this.showFeedbackDialog = false; + this.loadFeedbackElement({ id: this.feedbackElementId }); + }, async copy() { - await this.copyUnit({unitId: this.unit.id, modified: null}); + await this.copyUnit({ unitId: this.unit.id, modified: null }); this.companionSuccess({ info: this.$gettext('Lernmaterial kopiert.') }); }, } diff --git a/resources/vue/components/courseware/unit/CoursewareUnitItemDialogSettings.vue b/resources/vue/components/courseware/unit/CoursewareUnitItemDialogSettings.vue index ba2eaf4072805fd5893a597b8985cdde7922d0ad..d3534ee9670706c2670ba497aed32f754031b395 100644 --- a/resources/vue/components/courseware/unit/CoursewareUnitItemDialogSettings.vue +++ b/resources/vue/components/courseware/unit/CoursewareUnitItemDialogSettings.vue @@ -29,6 +29,23 @@ </select> </label> </fieldset> + <fieldset> + <legend>{{ $gettext('Feedback') }}</legend> + <label> + {{ $gettext('Am Ende eines Kapitels einen Dialog für die Bewertung anzeigen') }} + <select class="size-s" v-model="currentShowFeedbackPopup"> + <option value="0">{{ $gettext('Nein') }}</option> + <option value="1">{{ $gettext('Ja') }}</option> + </select> + <label> + {{ $gettext('Bewertung auf der Seite anzeigen') }} + <select class="size-s" v-model="currentShowFeedbackInContentbar"> + <option value="0">{{ $gettext('Nein') }}</option> + <option value="1">{{ $gettext('Ja') }}</option> + </select> + </label> + </label> + </fieldset> <fieldset v-if="certificatesRemindersEnabled"> <legend>{{ $gettext('Zertifikate') }}</legend> <label> @@ -211,6 +228,8 @@ export default { currentRootLayout: 'default', currentPermissionLevel: '', currentProgression: 0, + currentShowFeedbackPopup: 0, + currentShowFeedbackInContentbar: 1, makeCert: false, certThreshold: 0, certImage: '', @@ -263,6 +282,8 @@ export default { this.currentRootLayout = this.currentInstance.attributes['root-layout']; this.currentPermissionLevel = this.currentInstance.attributes['editing-permission-level']; this.currentProgression = this.currentInstance.attributes['sequential-progression'] ? '1' : '0'; + this.currentShowFeedbackPopup = this.currentInstance.attributes['show-feedback-popup'] ? '1' : '0'; + this.currentShowFeedbackInContentbar = this.currentInstance.attributes['show-feedback-in-contentbar'] ? '1' : '0'; this.certSettings = this.currentInstance.attributes['certificate-settings']; this.makeCert = typeof(this.certSettings) === 'object' && Object.keys(this.certSettings).length > 0; @@ -290,6 +311,8 @@ export default { this.currentInstance.attributes['root-layout'] = this.currentRootLayout; this.currentInstance.attributes['editing-permission-level'] = this.currentPermissionLevel; this.currentInstance.attributes['sequential-progression'] = this.currentProgression; + this.currentInstance.attributes['show-feedback-popup'] = this.currentShowFeedbackPopup; + this.currentInstance.attributes['show-feedback-in-contentbar'] = this.currentShowFeedbackInContentbar; this.currentInstance.attributes['certificate-settings'] = this.generateCertificateSettings(); this.currentInstance.attributes['reminder-settings'] = this.generateReminderSettings(); this.currentInstance.attributes['reset-progress-settings'] = this.generateResetProgressSettings(); diff --git a/resources/vue/components/courseware/widgets/CoursewareActivitiesWidgetFilterType.vue b/resources/vue/components/courseware/widgets/CoursewareActivitiesWidgetFilterType.vue index eb2c05f5184ff7645e41ab90d1d49826da1f09a6..09ced68f6c88661c9092b5a5a2ada9761b76e9a6 100644 --- a/resources/vue/components/courseware/widgets/CoursewareActivitiesWidgetFilterType.vue +++ b/resources/vue/components/courseware/widgets/CoursewareActivitiesWidgetFilterType.vue @@ -14,7 +14,7 @@ {{ $gettext('Erstellt') }} </option> <option value="answered"> - {{ $gettext('Feedback') }} + {{ $gettext('Angemerkt') }} </option> <option value="interacted"> {{ $gettext('Kommentiert') }} diff --git a/resources/vue/components/feedback/FeedbackCreateDialog.vue b/resources/vue/components/feedback/FeedbackCreateDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..204254c395aef2129e149e02c2fc2d72de43f243 --- /dev/null +++ b/resources/vue/components/feedback/FeedbackCreateDialog.vue @@ -0,0 +1,117 @@ +<template> + <div> + <studip-dialog + :title="$gettext('Feedback erstellen')" + :confirmText="$gettext('Erstellen')" + :closeText="$gettext('Schließen')" + closeClass="cancel" + confirmClass="accept" + height="420" + width="500" + @confirm="createFeedback" + @close="$emit('close')" + > + <template v-slot:dialogContent> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Frage') }} + <input type="text" v-model="question" > + </label> + <label> + {{ $gettext('Beschreibung') }} + <textarea v-model="description"></textarea> + </label> + <label> + <input type="checkbox" v-model="anonymous" > + {{ $gettext('Feedback kann anonym abgegeben werden') }} + </label> + <label> + <input type="checkbox" v-model="commentable" > + {{ $gettext('Abgegebenes Feedback kann einen Kommentar beinhalten') }} + </label> + + </form> + </template> + </studip-dialog> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'feedback-create-dialog', + props: { + defaultQuestion: { + type: String, + default: '' + }, + defaultDescription: { + type: String, + default: '' + }, + defaultCommentable: { + type: Boolean, + default: true + }, + defaultAnonymous: { + type: Boolean, + default: false + }, + rangeType: { + type: String, + required: true + }, + rangeId: { + type: String, + required: true + } + }, + data() { + return { + question: '', + description: '', + commentable: true, + anonymous: false + } + }, + methods: { + ...mapActions({ + createFeedbackElement: 'feedback-elements/create', + }), + createFeedback() { + const data = { + attributes: { + question: this.question, + description:this.description, + mode: 1, + 'results-visible': true, + 'is-commentable': this.commentable, + 'anonymous-entries': this.anonymous, + }, + relationships: { + range: { + data: { + type: this.rangeType, + id: this.rangeId, + }, + }, + }, + }; + this.createFeedbackElement(data).then(() => { + this.$emit('created'); + this.$emit('close'); + }); + }, + initData() { + this.question = this.defaultQuestion; + this.description = this.defaultDescription; + this.commentable = this.defaultCommentable; + this.anonymous = this.defaultAnonymous; + } + }, + mounted() { + this.initData(); + } +}; +</script> diff --git a/resources/vue/components/feedback/FeedbackDialog.vue b/resources/vue/components/feedback/FeedbackDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..e432b1b4354d32e68d3bdc6dc25950e311db4dbf --- /dev/null +++ b/resources/vue/components/feedback/FeedbackDialog.vue @@ -0,0 +1,223 @@ +<template> + <div> + <studip-dialog + :title="$gettext('Feedback')" + :closeText="$gettext('Schließen')" + closeClass="cancel" + :height="height" + :width="width" + @close="$emit('close')" + > + <template v-slot:dialogContent> + <div v-if="!loadingFeedbackElement" class="feedback-dialog"> + <feedback-five-stars-histogram :entries="entries" :vertical="true" /> + + <div class="feedback-dialog-content"> + <template v-if="!editElement"> + <div class="feedback-dialog-content-header"> + <h2> + {{ feedbackElement?.attributes?.question }} + </h2> + <button class="as-link" @click="editElement = true"> + <studip-icon shape="edit" /> + </button> + <button class="as-link" @click="showDeleteFeedbackDialog = true"> + <studip-icon shape="trash" /> + </button> + </div> + <div v-if="hasDescription"> + <h3>{{ $gettext('Beschreibung') }}</h3> + <p v-html="description"></p> + </div> + </template> + <feedback-element-update + v-else + :feedbackElementId="feedbackElementId" + @cancel="editElement = false" + @submit="updateFeedbackElement" + /> + + <template v-if="!currentUserIsAuthor"> + <h3>{{ $gettext('Meine Bewertung') }}</h3> + <feedback-entry-create + v-if="!hasCurrentUserEntry || editEntry" + :feedbackElement="feedbackElement" + :entry="currentUserEntry[0]" + :currentUser="currentUser" + @submit="editEntry = false" + @cancel="editEntry = false" + /> + <feedback-entry-box + v-else + class="current-user-entry" + :entry="currentUserEntry[0]" + :canEdit="true" + :canDelete="true" + @edit="editEntry = true" + @delete="showDeleteEntry" + /> + </template> + + <h3>{{ $gettext('Bewertungen') }}</h3> + <ul> + <li v-for="entry in otherUserEntries" :key="entry.id"> + <feedback-entry-box + :entry="entry" + :canDelete="canEditFeedbackElement" + @delete="showDeleteEntry" + /> + </li> + </ul> + <p v-if="entries.length === 0"> + {{ $gettext('Es wurden noch keine Bewertungen abgegeben.') }} + </p> + <p v-if="otherUserEntries.length === 0 && entries.length > 0"> + {{ $gettext('Es wurden noch keine weiteren Bewertungen abgegeben.') }} + </p> + </div> + <studip-dialog + v-if="showDeleteEntryDialog" + :title="$gettext('Feedback-Eintrag löschen')" + :question="$gettext('Möchten Sie den Eintrag wirklich unwiderruflich löschen?')" + height="200" + @confirm="executeDeleteFeedbackEntry" + @close="closeDeleteEntry" + /> + </div> + <studip-progress-indicator v-else :description="$gettext('Lade Bewertungen…')" /> + </template> + </studip-dialog> + <studip-dialog + v-if="showDeleteFeedbackDialog" + :title="$gettext('Feedback-Element löschen')" + :question=" + $gettext( + 'Möchten Sie das Feedback-Element wirklich unwiderruflich löschen? Alle Bewertungen werden ebenfalls gelöscht!' + ) + " + height="200" + @confirm="executeDeleteFeedback" + @close="showDeleteFeedbackDialog = false" + ></studip-dialog> + </div> +</template> +<script> +import FeedbackElementUpdate from './FeedbackElementUpdate.vue'; +import FeedbackEntryBox from './FeedbackEntryBox.vue'; +import FeedbackEntryCreate from './FeedbackEntryCreate.vue'; +import FeedbackFiveStarsHistogram from './FeedbackFiveStarsHistogram.vue'; +import StudipProgressIndicator from './../StudipProgressIndicator.vue'; + +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'feedback-dialog', + components: { + FeedbackElementUpdate, + FeedbackEntryBox, + FeedbackEntryCreate, + FeedbackFiveStarsHistogram, + StudipProgressIndicator, + }, + props: { + feedbackElementId: { + type: Number, + required: true, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + height: '0', + width: '0', + loadingFeedbackElement: false, + editEntry: false, + currentDeleteEntryId: null, + showDeleteEntryDialog: false, + showDeleteFeedbackDialog: false, + editElement: false, + }; + }, + computed: { + ...mapGetters({ + feedbackElementById: 'feedback-elements/byId', + canEditFeedbackElement: 'canEditFeedbackElement', + feedbackEntries: 'feedback-entries/all', + }), + entries() { + return this.feedbackEntries.filter( + (entry) => parseInt(entry.relationships?.['feedback-element']?.data?.id) === this.feedbackElementId + ); + }, + feedbackElement() { + return this.feedbackElementById({ id: this.feedbackElementId }) ?? null; + }, + currentUserIsAuthor() { + return this.currentUser.id === this.feedbackElement?.relationships?.author?.data?.id; + }, + currentUserEntry() { + return this.entries.filter((entry) => this.isUserEntry(entry)); + }, + otherUserEntries() { + return this.entries.filter((entry) => !this.isUserEntry(entry)); + }, + hasCurrentUserEntry() { + return this.currentUserEntry.length > 0; + }, + description() { + return this.feedbackElement?.attributes?.description; + }, + hasDescription() { + return this.description !== ''; + }, + }, + methods: { + ...mapActions({ + loadFeedbackElement: 'feedback-elements/loadById', + deleteFeedbackEntries: 'feedback-entries/delete', + deleteFeedbackElement: 'feedback-elements/delete', + }), + setDimensions() { + this.height = (window.innerHeight * 0.8).toFixed(0); + this.width = Math.min((window.innerWidth * 0.8).toFixed(0), 890).toFixed(0); + }, + isUserEntry(entry) { + return this.currentUser.id === entry.relationships?.author?.data?.id; + }, + showDeleteEntry(entry) { + this.currentDeleteEntryId = entry.id; + this.showDeleteEntryDialog = true; + }, + closeDeleteEntry() { + this.showDeleteEntryDialog = false; + this.currentDeleteEntryId = null; + }, + executeDeleteFeedbackEntry() { + this.deleteFeedbackEntries({ id: this.currentDeleteEntryId }); + this.closeDeleteEntry(); + }, + executeDeleteFeedback() { + this.deleteFeedbackElement({ id: this.feedbackElementId }).then(() => { + this.$emit('deleted'); + this.$emit('close'); + }); + }, + updateFeedbackElement() { + this.editElement = false; + this.loadElement(); + }, + async loadElement() { + this.loadingFeedbackElement = true; + await this.loadFeedbackElement({ id: this.feedbackElementId, options: { include: 'entries' } }); + this.loadingFeedbackElement = false; + }, + }, + mounted() { + this.setDimensions(); + this.loadElement(); + }, +}; +</script> diff --git a/resources/vue/components/feedback/FeedbackElementUpdate.vue b/resources/vue/components/feedback/FeedbackElementUpdate.vue new file mode 100644 index 0000000000000000000000000000000000000000..d9706de490f66e9103667583e68c9a772f728a37 --- /dev/null +++ b/resources/vue/components/feedback/FeedbackElementUpdate.vue @@ -0,0 +1,69 @@ +<template> + <form class="default feedback-element-update" @submit.prevent=""> + <h3>{{ $gettext('Feedback-Element bearbeiten') }}</h3> + <label> + {{ $gettext('Frage') }} + <input type="text" v-model="currentQuestion" /> + </label> + <label> + {{ $gettext('Beschreibung') }} + <textarea v-model="currentDescription"></textarea> + </label> + <div class="button-wrapper"> + <button class="button accept" @click="submitUpdate"> + {{ $gettext('Absenden') }} + </button> + <button class="button cancel" @click="$emit('cancel')"> + {{ $gettext('Abbrechen') }} + </button> + </div> + </form> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +export default { + name: 'feedback-element-update', + props: { + feedbackElementId: { + type: Number, + required: true, + }, + }, + data() { + return { + currentQuestion: '', + currentDescription: '', + }; + }, + computed: { + ...mapGetters({ + feedbackElementById: 'feedback-elements/byId', + }), + feedbackElement() { + return this.feedbackElementById({ id: this.feedbackElementId }); + }, + }, + methods: { + ...mapActions({ + updateFeedbackElement: 'feedback-elements/update', + }), + async submitUpdate() { + let data = { + id: this.feedbackElementId, + type: 'feedback-elements', + attributes: { + question: this.currentQuestion, + description: this.currentDescription, + }, + }; + await this.updateFeedbackElement(data); + this.$emit('submit'); + }, + }, + mounted() { + this.currentQuestion = this.feedbackElement.attributes?.question; + this.currentDescription = this.feedbackElement.attributes?.description.replace(/<\/?[^>]+>/gi, ' ').trim(); + }, +}; +</script> diff --git a/resources/vue/components/feedback/FeedbackEntryBox.vue b/resources/vue/components/feedback/FeedbackEntryBox.vue new file mode 100644 index 0000000000000000000000000000000000000000..0400c9d25a20cc6784ca806aa5146e9d5f01a57a --- /dev/null +++ b/resources/vue/components/feedback/FeedbackEntryBox.vue @@ -0,0 +1,106 @@ +<template> + <div class="feedback-entry-box" v-show="!loadingUser"> + <div class="feedback-entry-box-avatar"> + <img :src="avatarUrl" /> + </div> + <div class="feedback-entry-box-content"> + <h4>{{ title }}</h4> + <studip-five-stars :amount="parseInt(entry.attributes.rating)" :size="16" /> + <p>{{ entry.attributes.comment }}</p> + </div> + <div> + <button v-if="canEdit" class="as-link" @click="$emit('edit')"> + <studip-icon shape="edit" /> + </button> + <button v-if="canDelete" class="as-link" @click="deleteEntry"> + <studip-icon shape="trash" /> + </button> + </div> + </div> +</template> +<script> +import StudipFiveStars from './StudipFiveStars.vue'; + +import { mapActions, mapGetters } from 'vuex'; +export default { + name: 'feedback-entry-box', + components: { + StudipFiveStars, + }, + props: { + entry: { + type: Object, + required: true, + }, + name: { + type: String, + required: false, + }, + canEdit: { + type: Boolean, + default: false, + }, + canDelete: { + type: Boolean, + default: false, + }, + }, + data() { + return { + loadingUser: false, + }; + }, + computed: { + ...mapGetters({ + getUser: 'users/byId', + }), + title() { + return this.name ?? this.userName; + }, + userName() { + return this.user?.attributes?.['formatted-name'] ?? 'Anonym'; + }, + user() { + if (this.anonymous) { + return null; + } + const userId = this.entry.relationships?.author?.data?.id; + return this.getUser({ id: userId }); + }, + avatarUrl() { + return ( + this.user?.meta?.avatar?.small ?? STUDIP.URLHelper.getURL('assets/images/avatars/user/nobody_small.webp', {}, true) + ); + }, + anonymous() { + return this.entry.attributes.anonymous; + } + }, + methods: { + ...mapActions({ + loadUser: 'users/loadById', + }), + getEntryUser() { + this.loadingUser = true; + const userId = this.entry.relationships?.author?.data?.id; + const user = this.getUser({ id: userId }); + if (user) { + this.loadingUser = false; + return; + } + + this.loadUser({ id: userId }).then(() => { + this.loadingUser = false; + }); + }, + deleteEntry() { + this.$emit('delete', { id: this.entry.id }); + }, + }, + mounted() { + if (!this.anonymous) { + this.getEntryUser(); + } + }, +}; +</script> diff --git a/resources/vue/components/feedback/FeedbackEntryCreate.vue b/resources/vue/components/feedback/FeedbackEntryCreate.vue new file mode 100644 index 0000000000000000000000000000000000000000..5735d45738776dfd332eb70d4f0b2ab19df298a0 --- /dev/null +++ b/resources/vue/components/feedback/FeedbackEntryCreate.vue @@ -0,0 +1,114 @@ +<template> + <div v-if="feedbackElement" class="feedback-entry-create"> + <studip-five-stars-input v-model="rating" /> + <label v-if="isCommentable"> + {{ $gettext('Kommentar') }} + <textarea v-model="comment"></textarea> + </label> + <label v-if="anonymousEntriesEnabled"> + <input type="checkbox" v-model="anonymous" /> + {{ $gettext('Feedback anonym abgeben') }} + </label> + <div class="button-wrapper"> + <button class="button accept" @click="submitEntry"> + {{ $gettext('Absenden') }} + </button> + <button v-if="hasEntry" class="button cancel" @click="$emit('cancel')"> + {{ $gettext('Abbrechen') }} + </button> + + </div> + </div> +</template> + +<script> +import StudipFiveStarsInput from './StudipFiveStarsInput.vue'; +import { mapActions } from 'vuex'; + +export default { + name: 'feedback-entry-create', + components: { + StudipFiveStarsInput, + }, + props: { + feedbackElement: { + type: Object || null, + }, + entry: { + type: Object, + default: null, + }, + currentUser: { + type: Object, + required: true + } + }, + data() { + return { + rating: 0, + comment: '', + anonymous: false + }; + }, + computed: { + hasEntry() { + return this.entry !== null; + }, + anonymousEntriesEnabled() { + return this.feedbackElement?.attributes['anonymous-entries']; + }, + isCommentable() { + return this.feedbackElement?.attributes['is-commentable']; + } + }, + methods: { + ...mapActions({ + loadFeedbackEntriesById: 'feedback-entries/byId', + createFeedbackEntries: 'feedback-entries/create', + updateFeedbackEntries: 'feedback-entries/update', + }), + async submitEntry() { + let data = { + attributes: { + rating: this.rating, + }, + relationships: { + 'feedback-element': { + data: { + type: 'feedback-elements', + id: this.feedbackElement.id, + }, + }, + author: { + data: { + id: this.currentUser.id, + type: 'users' + } + } + }, + }; + if (this.isCommentable) { + data.attributes.comment = this.comment + } + if (this.anonymousEntriesEnabled) { + data.attributes.anonymous = this.anonymous; + } + if (this.hasEntry) { + data.id = this.entry.id; + data.type = this.entry.type; + await this.updateFeedbackEntries(data); + } else { + await this.createFeedbackEntries(data); + } + this.$emit('submit'); + }, + }, + mounted() { + if (this.hasEntry) { + this.rating = parseInt(this.entry.attributes.rating); + this.comment = this.entry.attributes.comment; + this.anonymous = this.entry.attributes.anonymous; + } + }, +}; +</script> \ No newline at end of file diff --git a/resources/vue/components/feedback/FeedbackFiveStarsHistogram.vue b/resources/vue/components/feedback/FeedbackFiveStarsHistogram.vue new file mode 100644 index 0000000000000000000000000000000000000000..b40245276ba41252d0dd1cb20f763b86be96e52f --- /dev/null +++ b/resources/vue/components/feedback/FeedbackFiveStarsHistogram.vue @@ -0,0 +1,91 @@ +<template> + <div class="five-stars-histogram" :class="{ vertical: vertical }"> + <div class="five-stars-histogram-average"> + <p class="fraction"> + <span class="average">{{ average.toFixed(1) }}</span + >/5 + </p> + <studip-five-stars :amount="average" /> + <p class="total"> + {{ + $gettextInterpolate($ngettext('%{n} Bewertung', '%{n} Bewertungen', entries.length), { + n: entries.length, + }) + }} + </p> + </div> + <div class="five-stars-histogram-chart" v-if="ratings"> + <div v-for="i in [5, 4, 3, 2, 1]" :key="'chart-' + i"> + <span>{{ i }} <studip-icon shape="star" role="info" /></span> + <div class="percentage"> + <div class="percentage-bar" :style="{ width: getRatePercentage(ratings[i]) }"> + {{ getRatePercentage(ratings[i]) }} + </div> + </div> + <span>{{ ratings[i] ?? 0 }}</span> + </div> + </div> + </div> +</template> +<script> +import StudipFiveStars from './StudipFiveStars.vue'; + +export default { + name: 'feedback-five-stars-histogram', + components: { + StudipFiveStars, + }, + props: { + entries: Array, + vertical: { + type: Boolean, + default: false, + }, + }, + data() { + return { + ratings: null, + }; + }, + computed: { + average() { + if (this.entries.length === 0) { + return 0; + } + let sum = this.entries.reduce((acc, entry) => acc + parseInt(entry.attributes.rating), 0); + + return sum / this.entries.length; + }, + }, + methods: { + getCountOfRatings() { + this.ratings = []; + this.entries.forEach((entry) => { + const rating = entry.attributes.rating; + if (this.ratings[rating]) { + this.ratings[rating] += 1; + } else { + this.ratings[rating] = 1; + } + }); + }, + getRatePercentage(rate) { + if (rate === undefined) { + return '0%'; + } + return parseInt((rate / this.entries.length) * 100, 10) + '%'; + }, + }, + mounted() { + this.getCountOfRatings(); + }, + watch: { + entries: { + handler() { + this.getCountOfRatings(); + }, + deep: true, + }, + }, +}; +</script> diff --git a/resources/vue/components/feedback/StudipFiveStars.vue b/resources/vue/components/feedback/StudipFiveStars.vue new file mode 100644 index 0000000000000000000000000000000000000000..1a4f40b327f3889d896f8354a7922061361e4c22 --- /dev/null +++ b/resources/vue/components/feedback/StudipFiveStars.vue @@ -0,0 +1,46 @@ +<template> + <div class="studip-five-stars"> + <studip-icon v-for="index in fullStars" :key="index+'full'" shape="star" :role="role" :size="size" /><studip-icon v-if="halfStar" shape="star-halffull" :role="role" :size="size" /><studip-icon v-for="index in emptyStars" :key="index+'empty'" shape="star-empty" :role="role" :size="size" /> + </div> +</template> + +<script> +import StudipIcon from './../StudipIcon.vue'; +export default { + name: 'studip-five-stars', + components: { + StudipIcon + }, + props: { + amount: { + type: Number, + required: true, + validator(value) { + return value <= 5 && value >= 0 + } + }, + role: { + type: String, + required: false, + default: 'status-yellow', + }, + size: { + type: Number, + required: false, + default: 24, + } + }, + computed: { + fullStars() { + return Math.floor(this.amount); + }, + halfStar() { + return this.amount - this.fullStars >= 0.5 + }, + emptyStars() { + const half = this.halfStar ? 1 : 0; + return 5 - this.fullStars - half; + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/feedback/StudipFiveStarsInput.vue b/resources/vue/components/feedback/StudipFiveStarsInput.vue new file mode 100644 index 0000000000000000000000000000000000000000..6fa95f09ce8602f13654767669dada90ba1660d1 --- /dev/null +++ b/resources/vue/components/feedback/StudipFiveStarsInput.vue @@ -0,0 +1,51 @@ +<template> + <div class="five-stars-input" :style="{ width: width + 'px' }"> + <button v-for="i in 5" :key="i" @click="setValue(i)"> + <studip-icon + :shape="getShape(i)" + :size="size" + :alt=" + $gettextInterpolate( + $ngettext( + 'auswählen, um mit einem Stern zu bewerten.', + 'auswählen, um mit %{i} Sternen zu bewerten.', + i + ), + { i: i } + ) + " + /> + </button> + </div> +</template> +<script> +import StudipIcon from './../StudipIcon.vue'; +export default { + name: 'studip-five-stars-input', + components: { + StudipIcon, + }, + props: { + value: { + type: Number, + }, + size: { + type: Number, + default: 24, + }, + }, + computed: { + width() { + return (this.size + 2 * 14) * 5; + }, + }, + methods: { + setValue(val) { + this.$emit('input', val); + }, + getShape(pos) { + return pos <= this.value ? 'star' : 'star-empty'; + }, + }, +}; +</script> diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index e32baed7d259150b1d0876c32e3d3e1bcd294016..191385f47c3bc944e3681a1b73f2193a31a7f06f 100644 --- a/resources/vue/courseware-index-app.js +++ b/resources/vue/courseware-index-app.js @@ -27,6 +27,7 @@ const mountApp = async (STUDIP, createApp, element) => { let unit_id = null; let licenses = null; let elem; + let feedbackSettings = null; if ((elem = document.getElementById(element.substring(1))) !== undefined) { if (elem.attributes !== undefined) { @@ -50,6 +51,10 @@ const mountApp = async (STUDIP, createApp, element) => { if (elem.attributes['licenses'] !== undefined) { licenses = JSON.parse(elem.attributes['licenses'].value); } + + if (elem.attributes['feedback-settings'] !== undefined) { + feedbackSettings = JSON.parse(elem.attributes['feedback-settings'].value); + } } } const routes = [ @@ -105,6 +110,8 @@ const mountApp = async (STUDIP, createApp, element) => { 'courseware-user-data-fields', 'courseware-user-progresses', 'courseware-units', + 'feedback-elements', + 'feedback-entries', 'files', 'file-refs', 'folders', @@ -147,6 +154,7 @@ const mountApp = async (STUDIP, createApp, element) => { if (entry_type === 'courses') { await store.dispatch('loadTeacherStatus', STUDIP.USER_ID); store.dispatch('loadProgresses'); + await store.dispatch('setFeedbackSettings', feedbackSettings); } store.dispatch('coursewareCurrentElement', elem_id); diff --git a/resources/vue/courseware-shelf-app.js b/resources/vue/courseware-shelf-app.js index 68a8ac97debe84cbbd8fefa5260a92033cf0e931..e212fd0af32f67bc49a839bd2ab709fd70d8b2b7 100644 --- a/resources/vue/courseware-shelf-app.js +++ b/resources/vue/courseware-shelf-app.js @@ -30,6 +30,7 @@ const mountApp = async (STUDIP, createApp, element) => { let entry_id = null; let entry_type = null; let licenses = null; + let feedbackSettings = null; if ((elem = document.getElementById(element.substring(1))) !== undefined) { if (elem.attributes !== undefined) { @@ -44,6 +45,9 @@ const mountApp = async (STUDIP, createApp, element) => { if (elem.attributes['licenses'] !== undefined) { licenses = JSON.parse(elem.attributes['licenses'].value); } + if (elem.attributes['feedback-settings'] !== undefined) { + feedbackSettings = JSON.parse(elem.attributes['feedback-settings'].value); + } } } @@ -64,6 +68,8 @@ const mountApp = async (STUDIP, createApp, element) => { 'courseware-user-progresses', 'courseware-structural-elements', 'courseware-structural-elements-shared', + 'feedback-elements', + 'feedback-entries', 'files', 'file-refs', 'folders', @@ -91,6 +97,7 @@ const mountApp = async (STUDIP, createApp, element) => { if (entry_type === 'courses') { await store.dispatch('loadTeacherStatus', STUDIP.USER_ID); await store.dispatch('loadCourseUnits', entry_id); + await store.dispatch('setFeedbackSettings', feedbackSettings); } else { await store.dispatch('loadUserUnits', entry_id); await store.dispatch('courseware-structural-elements-shared/loadAll', { options: { include: 'owner' } }); diff --git a/resources/vue/store/courseware/courseware-shelf.module.js b/resources/vue/store/courseware/courseware-shelf.module.js index 641ec51af4c881d86d1ea4cd97dbe22c730040c3..dc92a23ae4601a15a48452ec86aec2fd2ee3af89 100644 --- a/resources/vue/store/courseware/courseware-shelf.module.js +++ b/resources/vue/store/courseware/courseware-shelf.module.js @@ -25,6 +25,8 @@ const getDefaultState = () => { importStructuresState: '', importStructuresProgress: 0, importErrors: [], + + feedbackSettings: null, }; }; @@ -103,6 +105,23 @@ const getters = { importErrors(state) { return state.importErrors; }, + feedbackSettings(state) { + return state.feedbackSettings; + }, + isFeedbackActivated(state, getters) { + return getters.feedbackSettings?.activated ?? false; + }, + canCreateFeedbackElement(state, getters) { + return getters.feedbackSettings?.createPerm ?? false; + }, + canEditFeedbackElement(state, getters) { + return getters.feedbackSettings?.adminPerm ?? false; + }, + + currentUser(state, getters, rootState, rootGetters) { + const id = getters.userId; + return rootGetters['users/byId']({ id }); + }, }; export const state = { ...initialState }; @@ -158,11 +177,15 @@ export const actions = { context.commit('setUrlHelper', urlHelper); }, + setFeedbackSettings(context, feedbackSettings) { + context.commit('setFeedbackSettings', feedbackSettings); + }, + // other actions loadCourseUnits({ dispatch }, cid) { const parent = { type: 'courses', id: cid }; const relationship = 'courseware-units'; - const options = { include: 'structural-element' } + const options = { include: 'structural-element, feedback-element' } return dispatch('loadRelatedPaginated', { type: 'courseware-units', @@ -802,6 +825,10 @@ export const mutations = { setImportStructuresProgress(state, importStructuresProgress) { state.importStructuresProgress = importStructuresProgress; }, + + setFeedbackSettings(state, feedbackSettings) { + state.feedbackSettings = feedbackSettings; + } }; export default { diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 2f4dc60641803d00562856b9dd8b85c9210c3059..d5915b418d31444b0b232df98fb80aab37082de9 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -39,6 +39,8 @@ const getDefaultState = () => { showStructuralElementOerDialog: false, showStructuralElementPublicLinkDialog: false, showStructuralElementRemoveLockDialog: false, + showStructuralElementFeedbackDialog: false, + showStructuralElementFeedbackCreateDialog: false, showSuggestOerDialog: false, @@ -65,6 +67,7 @@ const getDefaultState = () => { progresses: null, toolbarActive: true, + feedbackSettings: null, }; }; @@ -74,6 +77,10 @@ const getters = { msg(state) { return state.msg; }, + currentUser(state, getters, rootState, rootGetters) { + const id = getters.userId; + return rootGetters['users/byId']({ id }); + }, lastElement(state) { return state.lastElement; }, @@ -86,6 +93,9 @@ const getters = { showRootElement(state, getters) { return getters.rootLayout !== 'none'; }, + rootId(state, getters) { + return getters.courseware?.relationships?.root?.data?.id; + }, currentElement(state) { return state.currentElement; }, @@ -220,6 +230,12 @@ const getters = { showStructuralElementRemoveLockDialog(state) { return state.showStructuralElementRemoveLockDialog; }, + showStructuralElementFeedbackDialog(state) { + return state.showStructuralElementFeedbackDialog; + }, + showStructuralElementFeedbackCreateDialog(state) { + return state.showStructuralElementFeedbackCreateDialog; + }, showOverviewElementAddDialog(state) { return state.showOverviewElementAddDialog; }, @@ -281,7 +297,19 @@ const getters = { toolbarActive(state) { return state.toolbarActive; - } + }, + feedbackSettings(state) { + return state.feedbackSettings; + }, + isFeedbackActivated(state, getters) { + return getters.feedbackSettings?.activated ?? false; + }, + canCreateFeedbackElement(state, getters) { + return getters.feedbackSettings?.createPerm ?? false; + }, + canEditFeedbackElement(state, getters) { + return getters.feedbackSettings?.adminPerm ?? false; + }, }; export const state = { ...initialState }; @@ -1015,6 +1043,13 @@ export const actions = { context.commit('setShowStructuralElementRemoveLockDialog', bool); }, + showStructuralElementFeedbackDialog(context, bool) { + context.commit('setShowStructuralElementFeedbackDialog', bool); + }, + showStructuralElementFeedbackCreateDialog(context, bool) { + context.commit('setShowStructuralElementFeedbackCreateDialog', bool); + }, + setShowOverviewElementAddDialog(context, bool) { context.commit('setShowOverviewElementAddDialog', bool); }, @@ -1502,7 +1537,10 @@ export const actions = { toggleToolbarActive({ commit, rootGetters }) { commit('setToolbarActive', !rootGetters['toolbarActive']); - } + }, + setFeedbackSettings(context, feedbackSettings) { + context.commit('setFeedbackSettings', feedbackSettings); + }, }; /* eslint no-param-reassign: ["error", { "props": false }] */ @@ -1649,6 +1687,13 @@ export const mutations = { state.showStructuralElementRemoveLockDialog = showRemoveLock; }, + setShowStructuralElementFeedbackDialog(state, showFeedback) { + state.showStructuralElementFeedbackDialog = showFeedback; + }, + setShowStructuralElementFeedbackCreateDialog(state, showFeedbackCreate) { + state.showStructuralElementFeedbackCreateDialog = showFeedbackCreate; + }, + setImportFilesState(state, importFilesState) { state.importFilesState = importFilesState; }, @@ -1700,6 +1745,9 @@ export const mutations = { }, setToolbarActive(state, active) { state.toolbarActive = active; + }, + setFeedbackSettings(state, feedbackSettings) { + state.feedbackSettings = feedbackSettings; } };