aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRon Lucke <lucke@elan-ev.de>2024-01-10 14:20:49 +0000
committerRon Lucke <lucke@elan-ev.de>2024-01-10 14:20:49 +0000
commit7293abbad9c1a149cfffd99c9ab5060fe945b773 (patch)
tree6e80b665c4cda71829570e6f0925f4d876e25fde
parent874bd358f5ecc2e148a6ad36bcf155fe738c107a (diff)
StEP #2472
Merge request studip/studip!2296
-rw-r--r--app/controllers/course/courseware.php6
-rw-r--r--app/controllers/course/feedback.php10
-rw-r--r--app/views/course/courseware/courseware.php1
-rw-r--r--app/views/course/courseware/index.php1
-rw-r--r--app/views/course/feedback/_add_edit_entry_form.php6
-rw-r--r--app/views/course/feedback/_entry.php5
-rw-r--r--app/views/course/feedback/_new_edit_feedback_form.php12
-rw-r--r--app/views/course/feedback/index.php4
-rw-r--r--db/migrations/5.5.22_add_feedback_anonymous_entries.php27
-rw-r--r--lib/classes/FeedbackRange.interface.php7
-rw-r--r--lib/classes/JsonApi/RouteMap.php10
-rw-r--r--lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php1
-rw-r--r--lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php28
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/Authority.php67
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/FeedbackElementsCreate.php114
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/FeedbackElementsDelete.php39
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/FeedbackElementsShow.php17
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/FeedbackElementsUpdate.php94
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesCreate.php115
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesDelete.php40
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesShow.php12
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/FeedbackEntriesUpdate.php95
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/RangeTypeAware.php20
-rw-r--r--lib/classes/JsonApi/Routes/Feedback/RatingHelper.php31
-rw-r--r--lib/classes/JsonApi/Schemas/Courseware/Instance.php2
-rw-r--r--lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php23
-rw-r--r--lib/classes/JsonApi/Schemas/Courseware/Unit.php11
-rw-r--r--lib/classes/JsonApi/Schemas/FeedbackElement.php49
-rw-r--r--lib/classes/JsonApi/Schemas/FeedbackEntry.php1
-rw-r--r--lib/models/Courseware/Instance.php45
-rw-r--r--lib/models/Courseware/StructuralElement.php47
-rw-r--r--lib/models/Courseware/Unit.php50
-rw-r--r--lib/models/FeedbackElement.php18
-rw-r--r--lib/models/FeedbackEntry.php2
-rw-r--r--lib/modules/CoursewareModule.class.php2
-rw-r--r--public/assets/images/icons/black/feedback.svg1
-rw-r--r--public/assets/images/icons/blue/feedback.svg1
-rw-r--r--public/assets/images/icons/green/feedback.svg1
-rw-r--r--public/assets/images/icons/grey/feedback.svg1
-rw-r--r--public/assets/images/icons/red/feedback.svg1
-rw-r--r--public/assets/images/icons/white/feedback.svg1
-rw-r--r--public/assets/images/icons/yellow/feedback.svg1
-rw-r--r--resources/assets/stylesheets/scss/courseware.scss2
-rw-r--r--resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss4
-rw-r--r--resources/assets/stylesheets/scss/courseware/layouts/tile.scss11
-rw-r--r--resources/assets/stylesheets/scss/feedback.scss151
-rw-r--r--resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue8
-rw-r--r--resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue2
-rw-r--r--resources/vue/components/courseware/CoursewareDashboardStudents.vue26
-rw-r--r--resources/vue/components/courseware/CoursewareDashboardTasks.vue6
-rw-r--r--resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue10
-rw-r--r--resources/vue/components/courseware/ShelfApp.vue5
-rw-r--r--resources/vue/components/courseware/blocks/CoursewareBlockFeedback.vue4
-rw-r--r--resources/vue/components/courseware/structural-element/CoursewareFeedbackPopup.vue109
-rw-r--r--resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue229
-rw-r--r--resources/vue/components/courseware/structural-element/CoursewareStructuralElementDiscussion.vue2
-rw-r--r--resources/vue/components/courseware/structural-element/CoursewareStructuralElementFeedback.vue17
-rw-r--r--resources/vue/components/courseware/unit/CoursewareShelfDialogTopics.vue2
-rw-r--r--resources/vue/components/courseware/unit/CoursewareUnitItem.vue178
-rw-r--r--resources/vue/components/courseware/unit/CoursewareUnitItemDialogSettings.vue23
-rw-r--r--resources/vue/components/courseware/widgets/CoursewareActivitiesWidgetFilterType.vue2
-rw-r--r--resources/vue/components/feedback/FeedbackCreateDialog.vue117
-rw-r--r--resources/vue/components/feedback/FeedbackDialog.vue223
-rw-r--r--resources/vue/components/feedback/FeedbackElementUpdate.vue69
-rw-r--r--resources/vue/components/feedback/FeedbackEntryBox.vue106
-rw-r--r--resources/vue/components/feedback/FeedbackEntryCreate.vue114
-rw-r--r--resources/vue/components/feedback/FeedbackFiveStarsHistogram.vue91
-rw-r--r--resources/vue/components/feedback/StudipFiveStars.vue46
-rw-r--r--resources/vue/components/feedback/StudipFiveStarsInput.vue51
-rw-r--r--resources/vue/courseware-index-app.js8
-rw-r--r--resources/vue/courseware-shelf-app.js7
-rw-r--r--resources/vue/store/courseware/courseware-shelf.module.js29
-rw-r--r--resources/vue/store/courseware/courseware.module.js52
73 files changed, 2570 insertions, 153 deletions
diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php
index f8e721e..af7d0e9 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 dec2000..c6eb977 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 2dadc86..9de924d 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 81296cb..eea4063 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 b117b78..1481f57 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 29f35fb..b7473d1 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 224cd11..df6b821 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 602d445..87a2449 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 0000000..2e557b5
--- /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 f5eaaef..863c197 100644
--- a/lib/classes/FeedbackRange.interface.php
+++ b/lib/classes/FeedbackRange.interface.php
@@ -12,6 +12,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.
*
* @return string A human-friendly name for the FeedbackRange object instance.
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index e86617b..d4d5bbb 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 dde67bc..b09d0c8 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 8bb0196..5f02690 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 4439781..5437a07 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 0000000..1269d8c
--- /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 0000000..874a172
--- /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 5ecc593..849e58a 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 0000000..2c02352
--- /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 0000000..41efd01
--- /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 0000000..7afcdb1
--- /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 b591db3..4a85c69 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 0000000..ff64f57
--- /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 0000000..88fd1b1
--- /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 0000000..849cba7
--- /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 7df0cf6..114467a 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 ab1dd0f..e6ccafa 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 84c6ca2..901f2f0 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 143bb0c..7b9e8cb 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 b84bf77..37e275e 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 1084ed5..5f6c343 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 a63c73f..35a0184 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 bf08328..2a38a29 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 63a2186..468f146 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 4b4ea7c..9c6ab41 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 6766f8c..8deeda7 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 0000000..00ab1a4
--- /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 0000000..11c43a4
--- /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 0000000..a4e93bc
--- /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 0000000..bc66f12
--- /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 0000000..b927cad
--- /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 0000000..cf01a79
--- /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 0000000..de8e124
--- /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 d83a23e..dba99a7 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 f05a518..7f5b5fd 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 4fd93bd..a563244 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 a9c34e6..5e11197 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 32ba86e..d3fd69c 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 825ee8f..d157f5b 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 ff59669..bac31a6 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 caaea0b..6de9c13 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 a5ba72e..f9b6f1a 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 d587755..173be71 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 c13d500..8bc05c3 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 0000000..687ca3c
--- /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 143a03a..34d3145 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 3428d83..419b8b0 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 acecf63..8d9f958 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 eb6e888..70745e8 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 6c0ec6e..b1e82bf 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 ba2eaf4..d3534ee 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 eb2c05f..09ced68 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 0000000..204254c
--- /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 0000000..e432b1b
--- /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 0000000..d9706de
--- /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 0000000..0400c9d
--- /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 0000000..5735d45
--- /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 0000000..b402452
--- /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 0000000..1a4f40b
--- /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 0000000..6fa95f0
--- /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 e32baed..191385f 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 68a8ac9..e212fd0 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 641ec51..dc92a23 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 2f4dc60..d5915b4 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;
}
};