From ec684bbd1629803bee4c15faf78c9997aaf7daf5 Mon Sep 17 00:00:00 2001 From: Ron Lucke <lucke@elan-ev.de> Date: Mon, 26 Sep 2022 08:11:22 +0000 Subject: [PATCH] =?UTF-8?q?StEP00362:=20Rechte-=20und=20Zugriffsverwaltung?= =?UTF-8?q?=20f=C3=BCr=20Arbeitsplatz=20>=20Lernmaterialien?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #919 Merge request studip/studip!639 --- app/controllers/contents/courseware.php | 37 ++ app/controllers/multipersonsearch.php | 40 +- .../courseware/shared_content_courseware.php | 10 + lib/classes/JsonApi/RouteMap.php | 6 +- .../JsonApi/Routes/Courseware/Authority.php | 61 ++- .../Courseware/CoursewareInstancesHelper.php | 1 + .../StructuralElementsReleasedIndex.php | 59 ++ .../StructuralElementsSharedIndex.php | 77 +++ lib/models/Courseware/StructuralElement.php | 166 ++++-- lib/navigation/ContentsNavigation.php | 54 +- .../javascripts/bootstrap/courseware.js | 2 +- .../assets/stylesheets/scss/courseware.scss | 73 ++- .../stylesheets/scss/multi_person_search.scss | 8 + resources/assets/stylesheets/studip.scss | 1 + resources/vue/base-components.js | 4 +- resources/vue/components/StudipIcon.vue | 3 +- .../components/StudipMultiPersonSearch.vue | 190 +++++++ .../courseware/ContentOverviewApp.vue | 4 +- .../courseware/ContentReleasesApp.vue | 6 +- .../CoursewareContentOverviewElements.vue | 504 ++++++++++-------- .../CoursewareContentOverviewFilterWidget.vue | 106 +++- .../CoursewareContentPermissions.vue | 391 ++++++++++++++ .../courseware/CoursewareContentShared.vue | 173 ++++++ .../CoursewareStructuralElement.vue | 24 +- .../courseware/CoursewareTreeItem.vue | 3 + .../vue/courseware-content-overview-app.js | 3 + .../vue/courseware-content-releases-app.js | 5 +- .../vue/store/courseware/courseware.module.js | 20 + 28 files changed, 1704 insertions(+), 327 deletions(-) create mode 100755 app/views/contents/courseware/shared_content_courseware.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/StructuralElementsReleasedIndex.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/StructuralElementsSharedIndex.php create mode 100755 resources/assets/stylesheets/scss/multi_person_search.scss create mode 100644 resources/vue/components/StudipMultiPersonSearch.vue create mode 100755 resources/vue/components/courseware/CoursewareContentPermissions.vue create mode 100644 resources/vue/components/courseware/CoursewareContentShared.vue diff --git a/app/controllers/contents/courseware.php b/app/controllers/contents/courseware.php index c7edcaced56..860fdc64d06 100644 --- a/app/controllers/contents/courseware.php +++ b/app/controllers/contents/courseware.php @@ -432,4 +432,41 @@ class Contents_CoursewareController extends AuthenticatedController $this->render_pdf($element->pdfExport($this->user, $with_children), trim($element->title).'.pdf'); } + + /** + * To display the shared courseware + * + * @param string $entry_element_id the shared struct element id + */ + public function shared_content_courseware_action($entry_element_id) + { + global $perm, $user; + + $navigation = new Navigation(_('Geteiltes Lernmaterial'), 'dispatch.php/contents/courseware/shared_content_courseware/' . $entry_element_id); + Navigation::addItem('/contents/courseware/shared_content_courseware', $navigation); + Navigation::activateItem('/contents/courseware/shared_content_courseware'); + + $this->entry_element_id = $entry_element_id; + + $struct = \Courseware\StructuralElement::findOneBySQL( + "id = ? AND range_type = 'user'", + [$this->entry_element_id] + ); + + if (!$struct) { + throw new Trails_Exception(404, _('Der geteilte Inhalt kann nicht gefunden werden.')); + } + + if (!$struct->canRead($user) && !$struct->canEdit($user)) { + throw new AccessDeniedException(); + } + + $this->user_id = $struct->owner_id; + + $this->licenses = $this->getLicences(); + + $this->oer_enabled = Config::get()->OERCAMPUS_ENABLED && $perm->have_perm(Config::get()->OER_PUBLIC_STATUS); + + $this->setCoursewareSidebar(); + } } diff --git a/app/controllers/multipersonsearch.php b/app/controllers/multipersonsearch.php index d036315841f..01733cb3791 100644 --- a/app/controllers/multipersonsearch.php +++ b/app/controllers/multipersonsearch.php @@ -110,7 +110,8 @@ class MultipersonsearchController extends AuthenticatedController * This needs to be done in one single action to provider a similar * usability for no-JavaScript users as for JavaScript users. */ - public function no_js_form_action() { + public function no_js_form_action() + { if (!empty($_POST)) { CSRFProtection::verifyUnsafeRequest(); @@ -243,4 +244,41 @@ class MultipersonsearchController extends AuthenticatedController } + + public function ajax_search_vue_action($name) + { + $searchterm = Request::get('s'); + $searchterm = str_replace(',', ' ', $searchterm); + $searchterm = preg_replace('/\s+/u', ' ', $searchterm); + + $result = []; + // execute searchobject if searchterm is at least 3 chars long + if (mb_strlen($searchterm) >= 3) { + $mp = MultiPersonSearch::load($name); + $mp->setSearchObject(new StandardSearch('user_id')); + $searchObject = $mp->getSearchObject(); + $result = array_map(function ($r) { + return $r['user_id']; + }, $searchObject->getResults($searchterm, [], 50)); + $result = User::findFullMany($result, 'ORDER BY Nachname ASC, Vorname ASC'); + $alreadyMember = $mp->getDefaultSelectedUsersIDs(); + } + + $output = []; + foreach ($result as $user) { + $output[] = [ + 'id' => $user->id, + 'avatar' => Avatar::getAvatar($user->id)->getURL(Avatar::SMALL), + 'text' => "{$user->nachname}, {$user->vorname} -- {$user->perms} ({$user->username})", + 'selected' => $alreadyMember === null ? false : in_array($user->id, $alreadyMember), + 'nachname' => $user->nachname, + 'vorname' => $user->vorname, + 'username' => $user->username, + 'formatted-name' => trim($user->getFullName()) + ]; + } + $this->render_json($output); + } + + } diff --git a/app/views/contents/courseware/shared_content_courseware.php b/app/views/contents/courseware/shared_content_courseware.php new file mode 100755 index 00000000000..f9590593b15 --- /dev/null +++ b/app/views/contents/courseware/shared_content_courseware.php @@ -0,0 +1,10 @@ +<div + id="courseware-index-app" + entry-element-id="<?= $entry_element_id ?>" + entry-type="sharedusers" + entry-id="<?= $entry_element_id ?>" + oer-enabled='<?= $oer_enabled ?>' + oer-title="<?= Config::get()->OER_TITLE ?>" + licenses='<?= $licenses ?>' + > +</div> diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 478e319b97d..312a90ab4ef 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -308,7 +308,7 @@ class RouteMap private function addAuthenticatedCoursewareRoutes(RouteCollectorProxy $group): void { - $group->get('/{type:courses|users}/{id}/courseware', Routes\Courseware\CoursewareInstancesShow::class); + $group->get('/{type:courses|users|sharedusers}/{id}/courseware', Routes\Courseware\CoursewareInstancesShow::class); $group->patch('/courseware-instances/{id}', Routes\Courseware\CoursewareInstancesUpdate::class); $this->addRelationship( $group, @@ -420,6 +420,10 @@ class RouteMap $group->patch('/courseware-structural-element-feedback/{id}', Routes\Courseware\StructuralElementFeedbackUpdate::class); $group->delete('/courseware-structural-element-feedback/{id}', Routes\Courseware\StructuralElementFeedbackDelete::class); + $group->get('/courseware-structural-elements-shared', Routes\Courseware\StructuralElementsSharedIndex::class); + $group->get('/courseware-structural-elements-released', Routes\Courseware\StructuralElementsReleasedIndex::class); + + $group->get('/courseware-blocks/{id}/user-data-field', Routes\Courseware\UserDataFieldOfBlocksShow::class); $group->get('/courseware-user-data-fields/{id}', Routes\Courseware\UserDataFieldsShow::class); $group->patch('/courseware-user-data-fields/{id}', Routes\Courseware\UserDataFieldsUpdate::class); diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php index 5e30a415ceb..29bde20557e 100644 --- a/lib/classes/JsonApi/Routes/Courseware/Authority.php +++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php @@ -59,7 +59,23 @@ class Authority public static function canUpdateBlock(User $user, Block $resource) { if ($resource->isBlocked()) { - return $resource->getBlockerUserId() == $user->id; + $structural_element = $resource->container->structural_element; + + if ($structural_element->range_type === 'user') { + if ($structural_element->range_id === $user->id) { + return true; + } + + return $structural_element->canEdit($user); + } + + $perm = $GLOBALS['perm']->have_studip_perm( + $structural_element->course->config->COURSEWARE_EDITING_PERMISSION, + $structural_element->course->id, + $user->id + ); + + return $resource->getBlockerUserId() === $user->id || $perm; } return self::canUpdateContainer($user, $resource->container); @@ -72,7 +88,36 @@ class Authority public static function canUpdateEditBlocker(User $user, $resource) { - return $resource->edit_blocker_id == '' || $resource->edit_blocker_id === $user->id; + $structural_element = null; + if ($resource instanceof Block) { + $structural_element = $resource->container->structural_element; + } + if ($resource instanceof Container) { + $structural_element = $resource->structural_element; + } + if ($resource instanceof StructuralElement) { + $structural_element = $resource; + } + + if ($structural_element === null) { + return false; + } + + if ($structural_element->range_type === 'user') { + if ($structural_element->range_id === $user->id) { + return true; + } + + return $structural_element->canEdit($user); + } + + $perm = $GLOBALS['perm']->have_studip_perm( + $structural_element->course->config->COURSEWARE_EDITING_PERMISSION, + $structural_element->course->id, + $user->id + ); + + return $resource->edit_blocker_id == '' || $resource->edit_blocker_id === $user->id || $perm; } public static function canShowContainer(User $user, Container $resource) @@ -163,6 +208,18 @@ class Authority return $GLOBALS['perm']->have_perm('root', $user->id); } + public static function canIndexStructuralElementsShared(User $user) + { + //TODO ? + return true; + } + + public static function canIndexStructuralElementsReleased(User $user) + { + //TODO ? + return true; + } + public static function canReorderStructuralElements(User $user, $resource) { return self::canUpdateStructuralElement($user, $resource); diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php index 843f7c2a902..46a2e689d67 100644 --- a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php +++ b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php @@ -26,6 +26,7 @@ trait CoursewareInstancesHelper 'courses' => 'getCoursewareCourse', 'user' => 'getCoursewareUser', 'users' => 'getCoursewareUser', + 'sharedusers' => 'getSharedCoursewareUser', ]; if (!($method = $methods[$rangeType])) { throw new BadRequestException('Invalid range type: "' . $rangeType . '".'); diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsReleasedIndex.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsReleasedIndex.php new file mode 100644 index 00000000000..b4a8e1c1108 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsReleasedIndex.php @@ -0,0 +1,59 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElement; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Class StructuralElementsReleasedIndex. + */ +class StructuralElementsReleasedIndex extends JsonApiController +{ + protected $allowedPagingParameters = ['offset', 'limit']; + + protected $allowedIncludePaths = [ + 'ancestors', + 'children', + 'containers', + 'containers.blocks', + 'containers.blocks.edit-blocker', + 'containers.blocks.editor', + 'containers.blocks.owner', + 'containers.blocks.user-data-field', + 'containers.blocks.user-progress', + 'course', + 'editor', + 'owner', + 'parent', + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $user = $this->getUser($request); + if (!Authority::canIndexStructuralElementsReleased($user)) { + throw new AuthorizationFailedException(); + } + + list($offset, $limit) = $this->getOffsetAndLimit(); + $resources = []; + $contents = StructuralElement::findBySQL( + 'range_id = ? AND range_type = ? ORDER BY mkdate DESC', + [$user->id, 'user'] + ); + + foreach ($contents as $content) { + if ((count($content->read_approval) && count($content->read_approval['users']) > 0) || (count($content->write_approval) && count($content->write_approval['users']) > 0)) { + $resources[] = $content; + } + } + + return $this->getPaginatedContentResponse($resources, count($resources)); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsSharedIndex.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsSharedIndex.php new file mode 100644 index 00000000000..0dc7c0d447c --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsSharedIndex.php @@ -0,0 +1,77 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElement; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Class StructuralElementsSharedIndex. + */ +class StructuralElementsSharedIndex extends JsonApiController +{ + protected $allowedPagingParameters = ['offset', 'limit']; + + protected $allowedIncludePaths = [ + 'ancestors', + 'children', + 'containers', + 'containers.blocks', + 'containers.blocks.edit-blocker', + 'containers.blocks.editor', + 'containers.blocks.owner', + 'containers.blocks.user-data-field', + 'containers.blocks.user-progress', + 'course', + 'editor', + 'owner', + 'parent', + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $user = $this->getUser($request); + if (!Authority::canIndexStructuralElementsShared($user)) { + throw new AuthorizationFailedException(); + } + + list($offset, $limit) = $this->getOffsetAndLimit(); + $resources = []; + $contents = StructuralElement::findBySQL( + 'range_id != ? AND range_type = ? ORDER BY mkdate DESC', + [$user->id, 'user'] + ); + + foreach ($contents as $content) { + if (!count($content->read_approval) || !count($content->write_approval)) { + continue; + } + + $add_content = false; + + foreach ($content->read_approval['users'] as $listedUserPerm) { + if ($listedUserPerm['id'] == $user->id && $listedUserPerm['read']) { + $add_content = true; + } + } + + foreach ($content->write_approval['users'] as $listedUserPerm) { + if ($listedUserPerm['id'] == $user->id && $listedUserPerm['read']) { + $add_content = true; + } + } + + if ($add_content) { + $resources[] = $content; + } + } + + return $this->getPaginatedContentResponse($resources, count($resources)); + } +} diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index 8edabadb5fc..c242870002e 100644 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -169,14 +169,22 @@ class StructuralElement extends \SimpleORMap return self::getCourseware($courseId, 'course'); } - private static function getCourseware(string $rangeId, string $rangeType): ?StructuralElement + public static function getSharedCoursewareUser(string $root_id): ?StructuralElement { - /** @var ?StructuralElement $result */ - $result = self::findOneBySQL( - 'range_id = ? - AND range_type = ? AND parent_id IS NULL', - [$rangeId, $rangeType] - ); + return self::getCourseware('', '', $root_id); + } + + private static function getCourseware(string $rangeId, string $rangeType, string $root_id = null): ?StructuralElement + { + if ($root_id) { + $result = self::find($root_id); + } else { + $result = self::findOneBySQL( + 'range_id = ? + AND range_type = ? AND parent_id IS NULL', + [$rangeId, $rangeType] + ); + } return $result; } @@ -222,7 +230,11 @@ class StructuralElement extends \SimpleORMap switch ($this->range_type) { case 'user': - return $this->range_id === $user->id; + if ($this->range_id === $user->id) { + return true; + } + + return $this->hasWriteApproval($user); case 'course': $hasEditingPermission = $this->hasEditingPermission($user); @@ -273,11 +285,12 @@ class StructuralElement extends \SimpleORMap switch ($this->range_type) { case 'user': - // Kontext "user": Nutzende können nur ihre eigenen Strukturknoten sehen. - if ($this->range_id === $user->id) { + if ($this->range_id === $user->id) { return true; } + return $this->hasReadApproval($user); + $link = StructuralElement::findOneBySQL('target_id = ?', [$this->id]); if ($link) { return true; @@ -313,8 +326,11 @@ class StructuralElement extends \SimpleORMap switch ($this->range_type) { case 'user': - // Kontext "user": Nutzende können nur ihre eigenen Strukturknoten sehen. - return $this->range_id === $user->id; + if ($this->range_id === $user->id) { + return true; + } + + return $this->hasReadApproval($user); case 'course': if (!$GLOBALS['perm']->have_studip_perm('user', $this->range_id, $user->id)) { @@ -367,59 +383,133 @@ class StructuralElement extends \SimpleORMap private function hasReadApproval($user): bool { - if (!count($this->read_approval)) { + // this property is shared between all range types. + if ($this->read_approval['all']) { return true; } - if ($this->read_approval['all']) { - return true; + // now we also check against the perms for contents. + if ($this->range_type === 'user') { + return $this->hasUserReadApproval($user); + } else { + if (!count($this->read_approval)) { + return true; + } + + // find user in users + $users = $this->read_approval['users']; + foreach ($users as $approvedUserId) { + if ($approvedUserId === $user->id) { + return true; + } + } + + // find user in groups + $groups = $this->read_approval['groups']; + foreach ($groups as $groupId) { + /** @var ?\Statusgruppen $group */ + $group = \Statusgruppen::find($groupId); + if ($group && $group->isMember($user->id)) { + return true; + } + } + } + + return false; + } + + private function hasUserReadApproval($user): bool + { + if (!count($this->read_approval)) { + if ($this->isRootNode()) { + return false; + } + return $this->parent->hasUserReadApproval($user); } // find user in users $users = $this->read_approval['users']; - foreach ($users as $approvedUserId) { - if ($approvedUserId == $user->id) { + foreach ($users as $listedUserPerm) { + // now for contents, there is an expiry date defined. + if (!empty($listedUserPerm['expiry']) && strtotime($listedUserPerm['expiry']) < strtotime('today')) { + if ($this->isRootNode()) { + return false; + } + return $this->parent->hasUserReadApproval($user); + } + // In order to have a record of the users in the perms list of contents, + // we keep a full perm record in read_approval column, and set read property to true or false, + // this won't apply to write_approval column. + if ($listedUserPerm['id'] == $user->id && $listedUserPerm['read'] == true) { return true; } } + } + + private function hasWriteApproval($user): bool + { + // this property is shared between all range types. + if ($this->write_approval['all']) { + return true; + } - // find user in groups - $groups = $this->read_approval['groups']; - foreach ($groups as $groupId) { - /** @var ?\Statusgruppen $group */ - $group = \Statusgruppen::find($groupId); - if ($group && $group->isMember($user->id)) { + // now we also check against the perms for contents. + if ($this->range_type === 'user') { + return $this->hasUserWriteApproval($user); + } else { + if (!count($this->write_approval)) { + return false; + } + + if ($this->write_approval['all']) { + return true; + } + + // find user in users + $users = $this->write_approval['users']->getArrayCopy(); + if (in_array($user->id, $users)) { return true; } + + // find user in groups + foreach (\Statusgruppen::findMany($this->write_approval['groups']->getArrayCopy()) as $group) { + if ($group->isMember($user->id)) { + return true; + } + } } return false; } - private function hasWriteApproval($user): bool + private function hasUserWriteApproval($user): bool { if (!count($this->write_approval)) { - return false; - } - - if ($this->write_approval['all']) { - return true; + if ($this->isRootNode()) { + return false; + } + return $this->parent->hasUserWriteApproval($user); } // find user in users - $users = $this->write_approval['users']->getArrayCopy(); - if (in_array($user->id, $users)) { - return true; - } - - // find user in groups - foreach (\Statusgruppen::findMany($this->write_approval['groups']->getArrayCopy()) as $group) { - if ($group->isMember($user->id)) { + $users = $this->write_approval['users']; + foreach ($users as $listedUserPerm) { + // now for contents, there is an expiry date defined. + if (!empty($listedUserPerm['expiry']) && strtotime($listedUserPerm['expiry']) < strtotime('today')) { + if ($this->isRootNode()) { + return false; + } + return $this->parent->hasUserWriteApproval($user); + } + if ($listedUserPerm['id'] == $user->id) { return true; } } - return false; + if ($this->isRootNode()) { + return false; + } + return $this->parent->hasUserWriteApproval($user); } /** diff --git a/lib/navigation/ContentsNavigation.php b/lib/navigation/ContentsNavigation.php index 32cefa56fd9..8e814ef4728 100644 --- a/lib/navigation/ContentsNavigation.php +++ b/lib/navigation/ContentsNavigation.php @@ -47,30 +47,36 @@ class ContentsNavigation extends Navigation $courseware->setDescription(_('Erstellen und Sammeln von Lernmaterialien')); $courseware->setImage(Icon::create('courseware')); - $courseware->addSubNavigation( - 'overview', - new Navigation(_('Übersicht'), 'dispatch.php/contents/courseware/index') - ); - $courseware->addSubNavigation( - 'courseware', - new Navigation(_('Persönliche Lernmaterialien'), 'dispatch.php/contents/courseware/courseware') - ); - $courseware->addSubNavigation( - 'courseware_manager', - new Navigation(_('Verwaltung persönlicher Lernmaterialien'), 'dispatch.php/contents/courseware/courseware_manager') - ); - $courseware->addSubNavigation( - 'releases', - new Navigation(_('Freigaben'), 'dispatch.php/contents/courseware/releases') - ); - $courseware->addSubNavigation( - 'bookmarks', - new Navigation(_('Lesezeichen'), 'dispatch.php/contents/courseware/bookmarks') - ); - $courseware->addSubNavigation( - 'courses_overview', - new Navigation(_('Meine Veranstaltungen'), 'dispatch.php/contents/courseware/courses_overview') - ); + $courseware = new Navigation(_('Courseware')); + $courseware->setDescription(_('Erstellen und Sammeln von Lernmaterialien')); + $courseware->setImage(Icon::create('courseware')); + + $courseware->addSubNavigation( + 'overview', + new Navigation(_('Übersicht'), 'dispatch.php/contents/courseware/index') + ); + $courseware->addSubNavigation( + 'courseware', + new Navigation(_('Persönliche Lernmaterialien'), 'dispatch.php/contents/courseware/courseware') + ); + $courseware->addSubNavigation( + 'courseware_manager', + new Navigation(_('Verwaltung persönlicher Lernmaterialien'), 'dispatch.php/contents/courseware/courseware_manager') + ); + $courseware->addSubNavigation( + 'releases', + new Navigation(_('Freigaben'), 'dispatch.php/contents/courseware/releases') + ); + $courseware->addSubNavigation( + 'bookmarks', + new Navigation(_('Lesezeichen'), 'dispatch.php/contents/courseware/bookmarks') + ); + $courseware->addSubNavigation( + 'courses_overview', + new Navigation(_('Meine Veranstaltungen'), 'dispatch.php/contents/courseware/courses_overview') + ); + + $this->addSubNavigation('courseware', $courseware); $this->addSubNavigation('courseware', $courseware); } diff --git a/resources/assets/javascripts/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js index 87101506622..7124ac9a8cc 100644 --- a/resources/assets/javascripts/bootstrap/courseware.js +++ b/resources/assets/javascripts/bootstrap/courseware.js @@ -79,7 +79,7 @@ STUDIP.domReady(() => { if (document.getElementById('courseware-content-releases-app')) { STUDIP.Vue.load().then(({ createApp }) => { import( - /* webpackChunkName: "courseware-content-links-app" */ + /* webpackChunkName: "courseware-content-releases-app" */ '@/vue/courseware-content-releases-app.js' ).then(({ default: mountApp }) => { return mountApp(STUDIP, createApp, '#courseware-content-releases-app'); diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index f7ee74ea7f6..ab114ad4097 100644 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -94,6 +94,12 @@ c o n t e n t s * * * * * * * * */ .cw-content-overview { max-width: 1100px; + h2 { + margin: 0; + font-weight: 400; + padding: 5px 0; + font-size: 1.4em; + } } .cw-contents-overview-teaser { @@ -193,6 +199,22 @@ c o n t e n t s } } +.cw-content-courses { + h2 { + margin: 0; + font-weight: 400; + padding: 5px 0; + font-size: 1.4em; + } + ul.cw-tiles { + margin-bottom: 20px; + } +} + +.cw-contents-overview-personal { + margin-bottom: 2em; +} + /* * * * * * * * * * * c o n t e n t s e n d * * * * * * * * * * */ @@ -202,7 +224,8 @@ r i b b o n * * * * * */ $consum_ribbon_width: calc(100% - 58px); #course-courseware-index, -#contents-courseware-courseware { +#contents-courseware-courseware, +#contents-courseware-shared_content_courseware { &.consume { overflow: hidden; } @@ -725,6 +748,23 @@ ribbon end padding: 0; font-size: 1.25em; } + td.perm { + input.right, input.date { + cursor: pointer !important; + } + } + } + button.cw-add-persons { + margin-left: 4px; + } + button.cw-permission-delete { + width: 24px; + height: 24px; + border: none; + background-color: transparent; + @include background-icon(trash, clickable); + background-repeat: no-repeat; + cursor: pointer; } } @@ -3117,6 +3157,26 @@ a u d i o b l o c k a u d i o b l o c k e n d * * * * * * * * * * * * * */ +/* * * * * * * * * * * * * * * * * * * * +f o r m u l t i m e d i a b l o c k s +* * * * * * * * * * * * * * * * * * * */ +.cw-file-empty { + @include background-icon(file, info, 96); + border: solid thin $content-color-40; + background-position: center 1em; + background-repeat: no-repeat; + min-height: 140px; + padding: 1em; + p { + text-align: center; + padding-top: 106px; + } +} + +/* * * * * * * * * * * * * * * * * * * * * * * * +f o r m u l t i m e d i a b l o c k s e n d +* * * * * * * * * * * * * * * * * * * * * * * */ + /* * * * * * * * * * v i d e o b l o c k * * * * * * * * * * */ @@ -4808,17 +4868,6 @@ cw tiles end } /* courseware template preview end*/ -/* contents courseware courses */ -.cw-content-courses { - h2 { - margin-top: 0; - } - ul.cw-tiles { - margin-bottom: 20px; - } -} -/* contents courseware courses end*/ - /* * * * * * * * * * i n p u t f i l e * * * * * * * * * */ diff --git a/resources/assets/stylesheets/scss/multi_person_search.scss b/resources/assets/stylesheets/scss/multi_person_search.scss new file mode 100755 index 00000000000..e1e6270f150 --- /dev/null +++ b/resources/assets/stylesheets/scss/multi_person_search.scss @@ -0,0 +1,8 @@ +.studip-msp-vue { + a.msp-btn { + margin-left: 5px; + img { + vertical-align: middle; + } + } +} diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index deca8381a99..1b0d2e0cb34 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -99,6 +99,7 @@ @import "scss/typography"; @import "scss/user-administration"; @import "scss/wiki"; +@import "scss/multi_person_search"; // Class for DOM elements that should only be visible to Screen readers diff --git a/resources/vue/base-components.js b/resources/vue/base-components.js index 09dbac3fb54..ccff7c5652e 100644 --- a/resources/vue/base-components.js +++ b/resources/vue/base-components.js @@ -19,6 +19,7 @@ import StudipProxyCheckbox from './components/StudipProxyCheckbox.vue'; import StudipProxiedCheckbox from './components/StudipProxiedCheckbox.vue'; import StudipTooltipIcon from './components/StudipTooltipIcon.vue'; import StudipSelect from './components/StudipSelect.vue'; +import StudipMultiPersonSearch from './components/StudipMultiPersonSearch.vue'; const BaseComponents = { Multiselect, @@ -41,7 +42,8 @@ const BaseComponents = { StudipProxiedCheckbox, StudipTooltipIcon, StudipSelect, - TextareaWithToolbar + TextareaWithToolbar, + StudipMultiPersonSearch }; export default BaseComponents; diff --git a/resources/vue/components/StudipIcon.vue b/resources/vue/components/StudipIcon.vue index 21b37a2616e..3a2ac79889b 100644 --- a/resources/vue/components/StudipIcon.vue +++ b/resources/vue/components/StudipIcon.vue @@ -28,7 +28,8 @@ if (this.shape.indexOf("http") === 0) { return this.shape; } - return `${STUDIP.ASSETS_URL}images/icons/${this.color}/${this.shape}.svg`; + var path = this.shape.split('+').reverse().join('/'); + return `${STUDIP.ASSETS_URL}images/icons/${this.color}/${path}.svg`; }, color: function () { switch (this.role) { diff --git a/resources/vue/components/StudipMultiPersonSearch.vue b/resources/vue/components/StudipMultiPersonSearch.vue new file mode 100644 index 00000000000..17a70cfc713 --- /dev/null +++ b/resources/vue/components/StudipMultiPersonSearch.vue @@ -0,0 +1,190 @@ +<template> + <div class="mpscontainer studip-msp-vue"> + <form method="post" class="default" @submit.prevent="search"> + <label class="with-action"> + <input type="text" ref="searchInputField" v-model="searchTerm" :placeholder="$gettext('Suchen')" style="width: 260px;"> + <a href="#" class="msp-btn" @click.prevent="search" :title="$gettext('Suche starten')"> + <studip-icon shape="search" role="clickable" size="16"></studip-icon> + </a> + <a href="#" class="msp-btn" @click.prevent="resetSearch" :title="$gettext('Suche zurücksetzen')"> + <studip-icon shape="decline" role="clickable" size="16"></studip-icon> + </a> + </label> + <select multiple="multiple" :id="select_box_id" name="selectbox[]"></select> + </form> + </div> +</template> + +<script> +export default { + name: 'studip-multi-person-search', + props: { + name: String, + withDetail: { + type: Boolean, + default: true + } + }, + data() { + return { + searchTerm: '', + count: 0, + users: [] + } + }, + mounted () { + this.$nextTick(() => { + this.init(); + setTimeout(() => { + this.$refs.searchInputField.focus(); + }, 100); + }); + }, + computed: { + id() { + return this._uid; + }, + count_text_id() { + return this.id + '_count'; + }, + select_box_id() { + return this.id + '_selectbox'; + }, + }, + methods: { + init() { + let select_all_btn = document.createElement('a'); + select_all_btn.setAttribute('id', `${this.id}-select-all`); + select_all_btn.setAttribute('href', '#'); + select_all_btn.innerText = this.$gettext('Alle hinzufügen'); + select_all_btn.addEventListener('click', (e) => { + e.preventDefault(); + this.selectAll(); + }); + let unselect_all_btn = document.createElement('a'); + unselect_all_btn.setAttribute('id', `${this.id}-unselect-all`); + unselect_all_btn.setAttribute('href', '#'); + unselect_all_btn.innerText = this.$gettext('Alle entfernen'); + unselect_all_btn.addEventListener('click', (e) => { + e.preventDefault(); + this.unselectAll(); + }); + let selection_header = document.createElement('div'); + selection_header.setAttribute('id', this.count_text_id); + selection_header.innerText = this.$gettextInterpolate('Sie haben %{ count } Personen ausgewählt', {count: this.count}); + + $('#' + this.select_box_id).multiSelect({ + selectableHeader: '<div>' + this.$gettext('Suchergebnisse') + '</div>', + selectionHeader: selection_header, + selectableFooter: select_all_btn, + selectionFooter: unselect_all_btn, + afterSelect: () => this.updateSelection(), + afterDeselect: () => this.updateSelection() + }); + }, + + search() { + this.users = []; + let view = this; + $.getJSON( + STUDIP.URLHelper.getURL('dispatch.php/multipersonsearch/ajax_search_vue/' + this.name, { s: this.searchTerm }), + function(data) { + view.removeAllNotSelected(); + var searchcount = 0; + $.each(data, function(i, item) { + searchcount += view.append( + item.id, + item.avatar + ' -- ' + item.text, + item.selected + ); + delete item.selected; + view.users.push(item); + }); + view.refresh(); + + if (searchcount === 0) { + view.append( + '--', + view.$gettextInterpolate('Es wurden keine neuen Ergebnisse für "%{ needle }" gefunden.', {needle: view.searchTerm}), + true + ); + view.refresh(); + } + } + ); + }, + + selectAll: function() { + $('#' + this.select_box_id).multiSelect('select_all'); + this.updateSelection(); + }, + + unselectAll: function() { + $('#' + this.select_box_id).multiSelect('deselect_all'); + this.updateSelection(); + }, + + removeAll: function() { + $('#' + this.select_box_id + ' option').remove(); + this.refresh(); + }, + + removeAllNotSelected() { + $('#' + this.select_box_id + ' option:not(:selected)').remove(); + this.refresh(); + }, + + resetSearch() { + this.searchTerm = ''; + this.removeAllNotSelected(); + }, + + append(id, text, selected = false) { + if ($('#' + this.select_box_id + ' option[value=' + id + ']').length === 0) { + $('#' + this.select_box_id).multiSelect('addOption', { + value: id, + text: text, + disabled: selected + }); + return 1; + } + return 0; + }, + + refresh() { + $('#' + this.select_box_id).multiSelect('refresh'); + this.updateSelection(); + }, + + updateCount(){ + this.count = $('#' + this.select_box_id + ' option:enabled:selected').length; + $('#' + this.count_text_id).text(this.$gettextInterpolate('Sie haben %{ count } Personen ausgewählt', {count: this.count})); + }, + + async updateSelection() { + this.updateCount(); + let selected_options = $('#' + this.select_box_id + ' option:enabled:selected'); + let user_ids = []; + if (selected_options.length) { + for (const option of selected_options) { + user_ids.push(option.value); + } + } + let return_value = []; + if (this.withDetail && this.users.length) { + for (const user_id of user_ids) { + let existing_index = this.users.findIndex(user => { + return user.id === user_id; + }); + if (existing_index !== -1) { + return_value.push(this.users[existing_index]); + } + } + } else { + return_value = user_ids; + } + this.$emit('input', return_value); + } + }, +} +</script> diff --git a/resources/vue/components/courseware/ContentOverviewApp.vue b/resources/vue/components/courseware/ContentOverviewApp.vue index cad3fc0076a..831a2e76cad 100644 --- a/resources/vue/components/courseware/ContentOverviewApp.vue +++ b/resources/vue/components/courseware/ContentOverviewApp.vue @@ -1,10 +1,10 @@ <template> <div class="cw-content-overview"> <courseware-content-overview-elements /> - <MountingPortal mountTo="#courseware-content-overview-action-widget" name="sidebar-views"> + <MountingPortal mountTo="#courseware-content-overview-action-widget" name="sidebar-actions"> <courseware-content-overview-action-widget /> </MountingPortal> - <MountingPortal mountTo="#courseware-content-overview-filter-widget" name="sidebar-views"> + <MountingPortal mountTo="#courseware-content-overview-filter-widget" name="sidebar-filters"> <courseware-content-overview-filter-widget /> </MountingPortal> </div> diff --git a/resources/vue/components/courseware/ContentReleasesApp.vue b/resources/vue/components/courseware/ContentReleasesApp.vue index 7dd550079eb..1a2ed6fcc9c 100644 --- a/resources/vue/components/courseware/ContentReleasesApp.vue +++ b/resources/vue/components/courseware/ContentReleasesApp.vue @@ -1,18 +1,22 @@ <template> <div class="cw-content-releases"> <courseware-content-links /> + <courseware-content-shared /> <courseware-companion-overlay /> </div> </template> <script> import CoursewareContentLinks from './CoursewareContentLinks.vue'; +import CoursewareContentShared from './CoursewareContentShared.vue'; import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; export default { components: { CoursewareContentLinks, + CoursewareContentShared, CoursewareCompanionOverlay - } + }, + } </script> diff --git a/resources/vue/components/courseware/CoursewareContentOverviewElements.vue b/resources/vue/components/courseware/CoursewareContentOverviewElements.vue index 09584757482..c23e6d621b1 100644 --- a/resources/vue/components/courseware/CoursewareContentOverviewElements.vue +++ b/resources/vue/components/courseware/CoursewareContentOverviewElements.vue @@ -1,214 +1,267 @@ <template> -<div v-if="root"> - <ul class="cw-tiles"> - <li - v-for="child in filteredChildren" - :key="child.id" - class="tile" - :class="[child.attributes.payload.color, filteredChildren.length > 3 ? '': 'cw-tile-margin']" - > - <a :href="getElementUrl(child.id)" :title="child.attributes.title"> - <div - class="preview-image" - :class="[hasImage(child) ? '' : 'default-image']" - :style="getChildStyle(child)" - ></div> - <div class="description"> - <header - :class="[child.attributes.purpose !== '' ? 'description-icon-' + child.attributes.purpose : '']" - > - {{ child.attributes.title }} - </header> - <div class="description-text-wrapper"> - <p>{{ child.attributes.payload.description }}</p> - </div> - <footer> - {{ countChildren(child) + 1 }} - <translate - :translate-n="countChildren(child) + 1" - translate-plural="Seiten" - > - Seite - </translate> - </footer> - </div> - </a> - </li> - </ul> - <courseware-companion-box v-if="children.length !== 0 && filteredChildren.length === 0 && purposeFilter !== 'all'" :msgCompanion="text.emptyFilter" mood="pointing"/> - <div v-if="children.length === 0" class="cw-contents-overview-teaser"> - <div class="cw-contents-overview-teaser-content"> - <header><translate>Ihre persönlichen Lernmaterialien</translate></header> - <p><translate>Erstellen und Verwalten Sie hier ihre eigenen persönlichen Lernmaterialien in Form von ePorfolios, - Vorlagen für Veranstaltungen oder einfach nur persönliche Inhalte für das Studium. - Entwickeln Sie ihre eigenen (Lehr-)Materialien für Studium oder die Lehre und teilen diese mit anderen Nutzenden.</translate></p> - <button class="button" @click="addElement"> - <translate>Neues Lernmaterial anlegen</translate> - </button> - </div> - </div> - <studip-dialog - v-if="showOverviewElementAddDialog" - :title="$gettext('Neues Lernmaterial anlegen')" - height="600" - width="500" - :confirmText="$gettext('Erstellen')" - confirmClass="accept" - :closeText="$gettext('Schließen')" - closeClass="cancel" - class="cw-structural-element-dialog" - @close="closeAddDialog" - @confirm="createElement" - > - <template v-slot:dialogContent> - - <courseware-collapsible-box - :title="$gettext('Grundeinstellungen')" - :open="true" + <div class="cw-contents-overview-wrapper"> + <div v-if="root && filteredChildren.length > 0" class="cw-contents-overview-personal"> + <h2> + <translate>Persönliche Lernmaterialien</translate> + </h2> + <ul class="cw-tiles"> + <li + v-for="child in filteredChildren" + :key="child.id" + class="tile" + :class="[child.attributes.payload.color, filteredChildren.length > 3 ? '': 'cw-tile-margin']" > - <form class="default" @submit.prevent=""> - <label> - <translate>Titel des Lernmaterials</translate><br /> - <input v-model="newElement.attributes.title" type="text" /> - </label> - <label> - <translate>Zusammenfassung</translate><br /> - <textarea v-model="newElement.attributes.payload.description"></textarea> - </label> - <label> - <translate>Bild</translate> - <br> - <input ref="upload_image" type="file" accept="image/*" @change="checkUploadFile" /> - <courseware-companion-box - v-if="uploadFileError" - :msgCompanion="uploadFileError" - mood="sad" - class="cw-companion-box-in-form" - /> - </label> - <label> - <translate>Art des Lernmaterials</translate> - <select v-model="newElementPurpose"> - <option value="content"><translate>Inhalt</translate></option> - <option value="template"><translate>Aufgabenvorlage</translate></option> - <option value="oer"><translate>OER-Material</translate></option> - <option value="portfolio"><translate>ePortfolio</translate></option> - <option value="draft"><translate>Entwurf</translate></option> - <option value="other"><translate>Sonstiges</translate></option> - </select> - </label> - <label> - <translate>Lernmaterialvorlage</translate> - <select v-model="newElementTemplate"> - <option :value="null"><translate>ohne Vorlage</translate></option> - <option - v-for="template in selectableTemplates" - :key="template.id" - :value="template" - > - {{ template.attributes.name }} - </option> - </select> - </label> - </form> - </courseware-collapsible-box> - <courseware-collapsible-box :title="$gettext('Vorschau')"> - <div v-if="currentTemplateStructure" class="cw-template-preview"> + <a :href="getElementUrl(child.id)" :title="child.attributes.title"> <div - class="cw-template-preview-container-wrapper" - v-for="container in currentTemplateStructure.containers" - :key="container.id" - :class="['cw-template-preview-container-' + container.attributes.payload.colspan]" - > - <div class="cw-template-preview-container-content"> - <header class="cw-template-preview-container-title"> - {{ container.attributes.title }} | {{ container.attributes.width }} - </header> - <div class="cw-template-preview-blocks" v-for="block in container.blocks" :key="block.id"> - <header class="cw-template-preview-blocks-title"> - {{ block.attributes.title }} + class="preview-image" + :class="[hasImage(child) ? '' : 'default-image']" + :style="getChildStyle(child)" + ></div> + <div class="description"> + <header + :class="[child.attributes.purpose !== '' ? 'description-icon-' + child.attributes.purpose : '']" + > + {{ child.attributes.title }} + </header> + <div class="description-text-wrapper"> + <p>{{ child.attributes.payload.description }}</p> + </div> + <footer> + {{ countChildren(child) + 1 }} + <translate + :translate-n="countChildren(child) + 1" + translate-plural="Seiten" + > + Seite + </translate> + </footer> + </div> + </a> + </li> + </ul> + </div> + <div v-if="children.length === 0" class="cw-contents-overview-teaser"> + <div class="cw-contents-overview-teaser-content"> + <header><translate>Ihre persönlichen Lernmaterialien</translate></header> + <p><translate>Erstellen und Verwalten Sie hier ihre eigenen persönlichen Lernmaterialien in Form von ePorfolios, + Vorlagen für Veranstaltungen oder einfach nur persönliche Inhalte für das Studium. + Entwickeln Sie ihre eigenen (Lehr-)Materialien für Studium oder die Lehre und teilen diese mit anderen Nutzenden.</translate></p> + <button class="button" @click="addElement"> + <translate>Neues Lernmaterial anlegen</translate> + </button> + </div> + </div> + <studip-dialog + v-if="showOverviewElementAddDialog" + :title="$gettext('Neues Lernmaterial anlegen')" + height="600" + width="500" + :confirmText="$gettext('Erstellen')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + class="cw-structural-element-dialog" + @close="closeAddDialog" + @confirm="createElement" + > + <template v-slot:dialogContent> + + <courseware-collapsible-box + :title="$gettext('Grundeinstellungen')" + :open="true" + > + <form class="default" @submit.prevent=""> + <label> + <translate>Titel des Lernmaterials</translate><br /> + <input v-model="newElement.attributes.title" type="text" /> + </label> + <label> + <translate>Zusammenfassung</translate><br /> + <textarea v-model="newElement.attributes.payload.description"></textarea> + </label> + <label> + <translate>Bild</translate> + <br> + <input ref="upload_image" type="file" accept="image/*" @change="checkUploadFile" /> + <courseware-companion-box + v-if="uploadFileError" + :msgCompanion="uploadFileError" + mood="sad" + class="cw-companion-box-in-form" + /> + </label> + <label> + <translate>Art des Lernmaterials</translate> + <select v-model="newElementPurpose"> + <option value="content"><translate>Inhalt</translate></option> + <option value="template"><translate>Aufgabenvorlage</translate></option> + <option value="oer"><translate>OER-Material</translate></option> + <option value="portfolio"><translate>ePortfolio</translate></option> + <option value="draft"><translate>Entwurf</translate></option> + <option value="other"><translate>Sonstiges</translate></option> + </select> + </label> + <label> + <translate>Lernmaterialvorlage</translate> + <select v-model="newElementTemplate"> + <option :value="null"><translate>ohne Vorlage</translate></option> + <option + v-for="template in selectableTemplates" + :key="template.id" + :value="template" + > + {{ template.attributes.name }} + </option> + </select> + </label> + </form> + </courseware-collapsible-box> + <courseware-collapsible-box :title="$gettext('Vorschau')"> + <div v-if="currentTemplateStructure" class="cw-template-preview"> + <div + class="cw-template-preview-container-wrapper" + v-for="container in currentTemplateStructure.containers" + :key="container.id" + :class="['cw-template-preview-container-' + container.attributes.payload.colspan]" + > + <div class="cw-template-preview-container-content"> + <header class="cw-template-preview-container-title"> + {{ container.attributes.title }} | {{ container.attributes.width }} </header> + <div class="cw-template-preview-blocks" v-for="block in container.blocks" :key="block.id"> + <header class="cw-template-preview-blocks-title"> + {{ block.attributes.title }} + </header> + </div> </div> </div> </div> - </div> - <courseware-companion-box - v-else - :msgCompanion="$gettext('Sie können eine Lernmaterialvorlage auswählen und hier eine Vorschau betrachten. Ohne Vorlage wird eine leere Seite erzeugt.')" - /> - </courseware-collapsible-box> - <courseware-collapsible-box - :title="$gettext('Zusatzangaben')" - > - <form class="default" @submit.prevent=""> - <label> - <translate>Lizenztyp</translate> - <select v-model="newElement.attributes.payload.license_type"> - <option v-for="license in licenses" :key="license.id" :value="license.id"> - {{ license.name }} - </option> - </select> - </label> - <label> - <translate>Geschätzter zeitlicher Aufwand</translate> - <input type="text" v-model="newElement.attributes.payload.required_time" /> - </label> - <label> - <translate>Niveau</translate><br /> - <translate>von</translate> - <select v-model="newElement.attributes.payload.difficulty_start"> - <option - v-for="difficulty_start in 12" - :key="difficulty_start" - :value="difficulty_start" - > - {{ difficulty_start }} - </option> - </select> - <translate>bis</translate> - <select v-model="newElement.attributes.payload.difficulty_end"> - <option - v-for="difficulty_end in 12" - :key="difficulty_end" - :value="difficulty_end" + <courseware-companion-box + v-else + :msgCompanion="$gettext('Sie können eine Lernmaterialvorlage auswählen und hier eine Vorschau betrachten. Ohne Vorlage wird eine leere Seite erzeugt.')" + /> + </courseware-collapsible-box> + <courseware-collapsible-box + :title="$gettext('Zusatzangaben')" + > + <form class="default" @submit.prevent=""> + <label> + <translate>Lizenztyp</translate> + <select v-model="newElement.attributes.payload.license_type"> + <option v-for="license in licenses" :key="license.id" :value="license.id"> + {{ license.name }} + </option> + </select> + </label> + <label> + <translate>Geschätzter zeitlicher Aufwand</translate> + <input type="text" v-model="newElement.attributes.payload.required_time" /> + </label> + <label> + <translate>Niveau</translate><br /> + <translate>von</translate> + <select v-model="newElement.attributes.payload.difficulty_start"> + <option + v-for="difficulty_start in 12" + :key="difficulty_start" + :value="difficulty_start" + > + {{ difficulty_start }} + </option> + </select> + <translate>bis</translate> + <select v-model="newElement.attributes.payload.difficulty_end"> + <option + v-for="difficulty_end in 12" + :key="difficulty_end" + :value="difficulty_end" + > + {{ difficulty_end }} + </option> + </select> + </label> + <label> + <translate>Farbe</translate> + <studip-select + v-model="newElement.attributes.payload.color" + :options="colors" + :reduce="(color) => color.class" + label="class" > - {{ difficulty_end }} - </option> - </select> - </label> - <label> - <translate>Farbe</translate> - <v-select - v-model="newElement.attributes.payload.color" - :options="colors" - :reduce="(color) => color.class" - label="class" + <template #open-indicator="selectAttributes"> + <span v-bind="selectAttributes" + ><studip-icon shape="arr_1down" size="10" + /></span> + </template> + <template #no-options="{ search, searching, loading }"> + <translate>Es steht keine Auswahl zur Verfügung.</translate> + </template> + <template #selected-option="{ name, hex }"> + <span class="vs__option-color" :style="{ 'background-color': hex }"></span + ><span>{{ name }}</span> + </template> + <template #option="{ name, hex }"> + <span class="vs__option-color" :style="{ 'background-color': hex }"></span + ><span>{{ name }}</span> + </template> + </studip-select> + </label> + </form> + </courseware-collapsible-box> + + </template> + </studip-dialog> + + <div v-if="filteredShared.length > 0" class="cw-contents-overview-shared"> + <h2> + <translate>Geteilte Lernmaterialien</translate> + </h2> + <ul class="cw-tiles"> + <li + v-for="element in filteredShared" + :key="element.id" + class="tile" + :class="[element.attributes.payload.color, sharedElements.length > 3 ? '': 'cw-tile-margin']" + > + <a :href="getSharedElementUrl(element.id)" :title="element.attributes.title"> + <div + class="preview-image" + :class="[hasImage(element) ? '' : 'default-image']" + :style="getChildStyle(element)" + > + <div class="overlay-text">{{ getOwnerName(element) }}</div> + </div> + <div class="description"> + <header + :class="[element.attributes.purpose !== '' ? 'description-icon-' + element.attributes.purpose : '']" > - <template #open-indicator="selectAttributes"> - <span v-bind="selectAttributes" - ><studip-icon shape="arr_1down" size="10" - /></span> - </template> - <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung.</translate> - </template> - <template #selected-option="{ name, hex }"> - <span class="vs__option-color" :style="{ 'background-color': hex }"></span - ><span>{{ name }}</span> - </template> - <template #option="{ name, hex }"> - <span class="vs__option-color" :style="{ 'background-color': hex }"></span - ><span>{{ name }}</span> - </template> - </v-select> - </label> - </form> - </courseware-collapsible-box> + {{ element.attributes.title }} + </header> + <div class="description-text-wrapper"> + <p>{{ element.attributes.payload.description }}</p> + </div> + <footer> + {{ countChildren(element) + 1 }} + <translate + :translate-n="countChildren(element) + 1" + translate-plural="Seiten" + > + Seite + </translate> + </footer> + </div> + </a> + </li> + </ul> + </div> + <courseware-companion-box + v-if="children.length !== 0 && filteredChildren.length === 0 && sharedElements.length !== 0 && filteredShared.length === 0" + :msgCompanion="$gettext('Für diese Auswahl wurden keine Lernmaterialien gefunden.')" + mood="pointing" + /> - </template> - </studip-dialog> - <courseware-companion-overlay /> -</div> + <courseware-companion-overlay /> + </div> </template> <script> @@ -228,10 +281,6 @@ export default { }, data() { return { - text: { - emptyFilter: this.$gettext('Für diese Auswahl wurden keine Lernmaterialien gefunden.'), - empty: this.$gettext('Es wurden keine Lernmaterialien gefunden.'), - }, newElement: { attributes: { payload: {}, @@ -246,9 +295,13 @@ export default { ...mapGetters({ getElement: 'courseware-structural-elements/byId', licenses: 'licenses', + permissionFilter: 'permissionFilter', purposeFilter: 'purposeFilter', + sourceFilter: 'sourceFilter', showOverviewElementAddDialog: 'showOverviewElementAddDialog', templates: 'courseware-templates/all', + sharedElements: 'courseware-structural-elements-shared/all', + userById: 'users/byId', }), root() { return this.getElement({id: STUDIP.COURSEWARE_USERS_ROOT_ID}); @@ -266,10 +319,32 @@ export default { return children; }, filteredChildren() { + if (!['all', 'personal'].includes(this.sourceFilter)) { + return []; + } + let children = this.children; + if (this.purposeFilter !== 'all') { + children = children.filter(child => { return child.attributes.purpose === this.purposeFilter}); + } + if (this.permissionFilter !== 'read') { + children = children.filter(child => { return child.attributes['can-edit'] }); + } + + return children; + }, + filteredShared() { + if (!['all', 'shared'].includes(this.sourceFilter)) { + return []; + } + let elements = this.sharedElements; if (this.purposeFilter !== 'all') { - return this.children.filter(child => { return child.attributes.purpose === this.purposeFilter}); + elements = elements.filter(element => { return element.attributes.purpose === this.purposeFilter}); + } + if (this.permissionFilter !== 'read') { + elements = elements.filter(element => { return element.attributes['can-edit'] }); } - return this.children; + + return elements; }, colors() { const colors = [ @@ -455,8 +530,17 @@ export default { hasImage(child) { return child.relationships?.image?.data !== null; }, - getElementUrl(element_id) { - return STUDIP.URLHelper.base_url + 'dispatch.php/contents/courseware/courseware#/structural_element/' + element_id; + getElementUrl(elementId) { + return STUDIP.URLHelper.base_url + 'dispatch.php/contents/courseware/courseware#/structural_element/' + elementId; + }, + getSharedElementUrl(elementId) { + return STUDIP.URLHelper.base_url + 'dispatch.php/contents/courseware/shared_content_courseware/' + elementId; + }, + getOwnerName(element) { + const ownerId = element.relationships.owner.data.id; + const owner = this.userById({ id: ownerId }); + + return owner.attributes['formatted-name']; }, addElement() { this.setShowOverviewElementAddDialog(true); @@ -519,7 +603,7 @@ export default { } else { this.uploadFileError = ''; } - }, + } }, watch: { root(newRootObject) { diff --git a/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue b/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue index 3fd93ab5067..43fbd1e0c9c 100644 --- a/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue +++ b/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue @@ -1,51 +1,99 @@ <template> - <select v-model="purposeFilter" class="sidebar-selectlist"> - <option value="all"> - <translate>alle</translate> - </option> - <option value="content"> - <translate>Inhalt</translate> - </option> - <option value="template"> - <translate>Aufgabenvorlage</translate> - </option> - <option value="oer"> - <translate>OER-Material</translate> - </option> - <option value="portfolio"> - <translate>ePortfolio</translate> - </option> - <option value="draft"> - <translate>Entwurf</translate> - </option> - <option value="other"> - <translate>Sonstiges</translate> - </option> - </select> + <div class="cw-filter-widget"> + <form class="default" @submit.prevent=""> + <label> + <translate>Lernmaterialien</translate> + <select v-model="sourceFilter"> + <option value="all"> + <translate>Alle</translate> + </option> + <option value="personal"> + <translate>Persönliche</translate> + </option> + <option value="shared"> + <translate>Geteilte</translate> + </option> + </select> + </label> + <label> + <translate>Zweck</translate> + <select v-model="purposeFilter"> + <option value="all"> + <translate>Alle</translate> + </option> + <option value="content"> + <translate>Inhalt</translate> + </option> + <option value="template"> + <translate>Aufgabenvorlage</translate> + </option> + <option value="oer"> + <translate>OER-Material</translate> + </option> + <option value="portfolio"> + <translate>ePortfolio</translate> + </option> + <option value="draft"> + <translate>Entwurf</translate> + </option> + <option value="other"> + <translate>Sonstiges</translate> + </option> + </select> + </label> + <label> + <translate>Rechte</translate> + <select v-model="permissionFilter"> + <option value="read"> + <translate>Lesen</translate> + </option> + <option value="write"> + <translate>Lesen und schreiben</translate> + </option> + </select> + </label> + </form> + </div> </template> <script> -import { mapActions, mapGetters } from 'vuex'; +import { mapActions } from 'vuex'; export default { name: 'courseware-content-overview-filter-widget', data() { return { - purposeFilter: 'all' + permissionFilter: 'read', + purposeFilter: 'all', + sourceFilter: 'all', }; }, methods: { ...mapActions({ - setPurposeFilter: 'setPurposeFilter' + setPermissionFilter: 'setPermissionFilter', + setPurposeFilter: 'setPurposeFilter', + setSourceFilter: 'setSourceFilter', }), + filterPermission() { + this.setPermissionFilter(this.permissionFilter); + }, filterPurpose() { this.setPurposeFilter(this.purposeFilter); - } + }, + filterSource() { + this.setSourceFilter(this.sourceFilter); + }, }, watch: { + permissionFilter() { + this.filterPermission(); + }, purposeFilter() { this.filterPurpose(); - } + }, + sourceFilter() { + this.filterSource(); + }, } } -</script> \ No newline at end of file +</script> diff --git a/resources/vue/components/courseware/CoursewareContentPermissions.vue b/resources/vue/components/courseware/CoursewareContentPermissions.vue new file mode 100755 index 00000000000..22640721aa2 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareContentPermissions.vue @@ -0,0 +1,391 @@ +<template> + <div class="cw-element-permissions"> + <studip-message-box v-if="message != false" + :type="message.type ? message.type : 'info'" + :details="message.details ? message.details : []" + :hideClose="false"> + {{ $gettext(message.text) }} + </studip-message-box> + <table class="default"> + <caption> + <translate>Personen</translate> + </caption> + <colgroup> + <col style="width:35%"> + <col style="width:15%"> + <col style="width:25%"> + <col style="width:15%"> + <col style="width:10%"> + </colgroup> + <thead> + <tr> + <th><translate>Name</translate></th> + <th><translate>Leserechte</translate></th> + <th><translate>Lese- und Schreibrechte</translate></th> + <th><translate>Ablaufdatum</translate></th> + <th class="actions"><translate>Aktion</translate></th> + </tr> + </thead> + <tbody> + <tr v-if="listEmpty" class="empty"> + <td colspan="5"> + <translate>Es wurden noch keine Freigaben erteilt</translate> + </td> + </tr> + <tr v-for="(user_perm, index) of userPermsList" :key="index"> + <td> + <label> + {{ user_perm['formatted-name'] }} + <i>{{ user_perm.username }}</i> + </label> + </td> + <td class="perm"> + <input + class="right" + :title="$gettextInterpolate('Leserechte für %{ userName }', { userName: user_perm.username })" + type="radio" + :name="`${user_perm.id}_right`" + value="read" + :checked="userPermsList[index]['read'] && !userPermsList[index]['write']" + @change="updateReadWritePerm(index, $event.target.value)" + /> + </td> + <td class="perm"> + <input + class="right" + :title="$gettextInterpolate('Lese- und Schreibrechte für %{ userName }', { userName: user_perm.username })" + type="radio" + :name="`${user_perm.id}_right`" + value="write" + :checked="userPermsList[index]['read'] && userPermsList[index]['write']" + @change="updateReadWritePerm(index, $event.target.value)" + /> + </td> + <td> + <input + style="cursor: pointer !important;" + :title="getExpiryTitle(user_perm.username, userPermsList[index]['expiry'])" + type="date" + :min="minDate" + :id="`${user_perm.id}_expiry`" + v-model="userPermsList[index]['expiry']" + @change="refreshReadWriteApproval" + /> + </td> + <td class="actions"> + <button + class="cw-permission-delete" + :title="$gettextInterpolate('Entfernen der Rechte von %{ userName }', { userName: user_perm.username })" + @click.prevent="confirmDeleteUserPerm(index)" + > + </button> + </td> + </tr> + </tbody> + <tfoot> + <tr> + <td colspan="5"> + <span class="multibuttons"> + <button class="button add cw-add-persons" @click.prevent="showAddMultiPersonDialog = true"> + <translate>Personen hinzufügen</translate> + </button> + <button + class="button" + :class="{disabled: listEmpty}" + :disabled="listEmpty" + @click.prevent="setAllPerms('read')" + > + <translate>Allen Leserechte geben</translate> + </button> + <button + class="button" + :class="{disabled: listEmpty}" + :disabled="listEmpty" + @click.prevent="setAllPerms('write')" + > + <translate>Allen Lese- und Schreibrechte geben</translate> + </button> + </span> + </td> + </tr> + </tfoot> + </table> + <studip-dialog + v-if="showAddMultiPersonDialog" + :title="$gettext('Personen hinzufügen')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + @close="clearSelectedUsers" + @confirm="getSelectedUsers" + height="500" + width="750" + > + <template v-slot:dialogContent> + <studip-multi-person-search v-model="selectedUsers" name="content-persons"/> + </template> + </studip-dialog> + <studip-dialog + v-if="showDeleteDialog" + :title="$gettext('Personen löschen')" + :question="$gettext('Möchten Sie diese Person wirklich löschen?')" + height="180" + @confirm="performDeleteUserPerm" + @close="clearDeleteUserPerm" + ></studip-dialog> + </div> +</template> +<script> +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-content-permissions', + props: { + element: Object, + }, + data() { + return { + showAddMultiPersonDialog: null, + userPermsList: [], + selectedUsers:[], + userPermsReadAll: false, + userPermsWriteAll: false, + userPermsReadUsers: [], + userPermsWriteUsers: [], + message: false, + showDeleteDialog: false, + deleteUserPermIndex: -1 + }; + }, + + mounted() { + if (this.element.attributes['read-approval'].all !== undefined) { + this.userPermsReadAll = this.element.attributes['read-approval'].all; + } else { + this.userPermsReadAll = false; + } + if (this.element.attributes['write-approval'].all !== undefined) { + this.userPermsWriteAll = this.element.attributes['write-approval'].all; + } else { + this.userPermsWriteAll = false; + } + this.initUserPermsList(); + }, + + computed: { + ...mapGetters({ + userById: 'users/byId', + }), + + listEmpty() { + return this.userPermsList.length === 0; + }, + + readApproval() { + return { + all: this.userPermsReadAll, + users: this.userPermsReadUsers, + groups: [] + }; + }, + + writeApproval() { + return { + all: this.userPermsWriteAll, + users: this.userPermsWriteUsers, + groups: [] + }; + }, + + minDate() { + let today = new Date(); + return today.toISOString().split('T')[0]; + } + }, + + methods: { + ...mapActions({ + loadUser: 'users/loadById', + }), + + getExpiryTitle(userName, date) { + if (date) { + return this.$gettextInterpolate('Die Berechtigungen für %{ userName } laufen am folgendem Datum ab: %{ dateStr }', { userName: userName, dateStr: new Date(date).toLocaleDateString() }) + } else { + return this.$gettextInterpolate('Das Ablaufdatum der Berechtigungen für %{ userName }', { userName: userName }); + } + }, + + async getUser(userId) { + await this.loadUser({id: userId}); + const user = this.userById({id: userId}); + return user; + }, + + async initUserPermsList() { + + if (this.element.attributes['read-approval'].users !== undefined) { + this.userPermsReadUsers = this.element.attributes['read-approval'].users; + } + + if (this.element.attributes['write-approval'].users !== undefined) { + this.userPermsWriteUsers = this.element.attributes['write-approval'].users; + } + + for (const user_perm_obj of this.userPermsReadUsers) { + let userObj = await this.getUser(user_perm_obj.id); + let writePerm = this.userPermsWriteUsers.some(user_write_perm => user_write_perm.id === user_perm_obj.id) ? true : false; + this.userPermsList.push({ + 'id' : user_perm_obj.id, + 'read': user_perm_obj.read, + 'write': writePerm, + 'expiry': user_perm_obj.expiry ? new Date(user_perm_obj.expiry).toISOString().split('T')[0] : '', + 'formatted-name': userObj.attributes['formatted-name'], + 'username': userObj.attributes['username'], + }); + } + }, + + async getSelectedUsers() { + this.message = false; + let duplicatedUsers = []; + if (this.selectedUsers.length) { + for (const selected_user of this.selectedUsers) { + let exists = this.userPermsList.some(user => { + return user.id === selected_user.id; + }); + if (!exists) { + let newUserPerm = { + 'id': selected_user.id, + 'read': true, + 'write': false, + 'expiry': '', + 'formatted-name': selected_user['formatted-name'], + 'username': selected_user.username, + }; + this.userPermsList.push(newUserPerm); + this.refreshReadWriteApproval(); + } else { + duplicatedUsers.push(selected_user); + } + } + this.selectedUsers = []; + } + this.showAddMultiPersonDialog = false; + + if (duplicatedUsers.length > 0) { + this.message = {}; + this.message.text = this.$gettext('Die folgenden ausgewählten Personen existierten bereits:'); + this.message.type = 'info'; + this.message.details = []; + for (const duplicated of duplicatedUsers) { + this.message.details.push(duplicated['formatted-name']); + } + } + }, + + clearSelectedUsers() { + this.selectedUsers = []; + this.showAddMultiPersonDialog = false; + }, + + confirmDeleteUserPerm(index) { + this.deleteUserPermIndex = index; + this.showDeleteDialog = true; + }, + + performDeleteUserPerm() { + if (this.deleteUserPermIndex !== -1) { + this.userPermsList.splice(this.deleteUserPermIndex, 1); + this.refreshReadWriteApproval(); + } + this.clearDeleteUserPerm(); + }, + + clearDeleteUserPerm() { + this.deleteUserPermIndex = -1; + this.showDeleteDialog = false; + }, + + updateReadWritePerm(index, value) { + let read = false; + let write = false; + + if (value === 'read') { + read = true; + } else if (value === 'write') { + read = true; + write = true; + } + + this.userPermsList[index]['read'] = read; + this.userPermsList[index]['write'] = write; + this.refreshReadWriteApproval(); + }, + + setAllPerms(permtype) { + if (this.listEmpty) { + return false; + } + let read = true; + let write = permtype === 'write'; + this.userPermsList.every(item => { + item['read'] = read; + item['write'] = write; + + return true; + }); + + this.refreshReadWriteApproval(); + }, + + refreshReadWriteApproval() { + this.refreshReadApproval(); + this.refreshWriteApproval(); + }, + + refreshReadApproval() { + this.userPermsReadUsers = []; + for (const user_perm_obj of this.userPermsList) { + let readRight = user_perm_obj.write ? true : user_perm_obj.read; + this.userPermsReadUsers.push({ + 'id': user_perm_obj.id, + 'read': readRight, + 'write': user_perm_obj.write, + 'expiry': user_perm_obj.expiry ? new Date(user_perm_obj.expiry).toISOString() : '' + }); + } + this.$emit('updateReadApproval', this.readApproval); + }, + + refreshWriteApproval() { + this.userPermsWriteUsers = []; + for (const user_perm_obj of this.userPermsList) { + if (user_perm_obj.write) { + this.userPermsWriteUsers.push({ + 'id': user_perm_obj.id, + 'expiry': user_perm_obj.expiry ? new Date(user_perm_obj.expiry).toISOString() : '' + }); + } + } + this.$emit('updateWriteApproval', this.writeApproval); + } + }, + + watch: { + userPermsReadAll(newVal, oldVal) { + this.$emit('updateReadApproval', this.readApproval); + if (newVal === true) { + this.userPermsWriteAll = false; + } + }, + userPermsWriteAll(newVal, oldVal) { + this.$emit('updateWriteApproval', this.writeApproval); + if (newVal === true) { + this.userPermsReadAll = false; + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/CoursewareContentShared.vue b/resources/vue/components/courseware/CoursewareContentShared.vue new file mode 100644 index 00000000000..19594d00302 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareContentShared.vue @@ -0,0 +1,173 @@ +<template> + <div> + <table class="default"> + <caption> + <translate>Von mir geteilte Lerninhalte</translate> + </caption> + <thead> + <tr> + <th><translate>Seite</translate></th> + <th><translate>Lesen</translate></th> + <th><translate>Lesen & Schreiben</translate></th> + <th class="actions"><translate>Aktionen</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="element in releasedElements" :key="element.id"> + <td> + <a :href="getElementUrl(element)"> + {{ element.attributes.title }} + </a> + </td> + <td> + <span + v-if="element.attributes['read-approval'].users.length > 0" + role="checkbox" + aria-checked="true" + aria-disabled="true" + > + <studip-icon shape="accept" role="info" /> + </span> + </td> + <td> + <span + v-if="element.attributes['write-approval'].users.length > 0" + role="checkbox" + aria-checked="true" + aria-disabled="true" + > + <studip-icon shape="accept" role="info" /> + </span> + </td> + <td class="actions"> + <studip-action-menu + :items="menuItems" + @editReleases="displayEditReleases(element)" + @clearReleases="displayClearReleases(element)" + /> + </td> + </tr> + </tbody> + </table> + + <studip-dialog + v-if="showEditReleases" + :title="$gettext('Freigabe bearbeiten')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="480" + width="720" + @confirm="storeReleases" + @close="closeEditReleases" + > + <template v-slot:dialogContent> + <courseware-content-permissions + :element="selectedElement" + @updateReadApproval="updateReadApproval" + @updateWriteApproval="updateWriteApproval" + /> + </template> + </studip-dialog> + + <studip-dialog + v-if="showClearReleases" + :title="$gettext('Löschen der Freigabe')" + :question="$gettextInterpolate('Möchten Sie die Freigabe für %{ pageTitle} wirklich löschen?', {pageTitle: this.selectedElement.attributes.title})" + height="220" + @confirm="clearReleases" + @close="closeClearReleases" + ></studip-dialog> + </div> +</template> + +<script> +import CoursewareContentPermissions from './CoursewareContentPermissions.vue'; +import { mapActions, mapGetters } from 'vuex'; +import StudipActionMenu from './../StudipActionMenu.vue'; +import StudipDialog from '../StudipDialog.vue'; +import StudipIcon from '../StudipIcon.vue'; + +export default { + name: 'courseware-content-shared', + components: { + CoursewareContentPermissions, + StudipActionMenu, + StudipDialog, + StudipIcon, + }, + data() { + return { + menuItems: [ + { id: 1, label: this.$gettext('Freigabe bearbeiten'), icon: 'edit', emit: 'editReleases' }, + { id: 2, label: this.$gettext('Freigabe löschen'), icon: 'trash', emit: 'clearReleases' } + ], + showClearReleases: false, + showEditReleases: false, + selectedElement: null + } + }, + computed: { + ...mapGetters({ + releasedElements: 'courseware-structural-elements-released/all', + }), + }, + methods: { + ...mapActions({ + updateStructuralElement: 'updateStructuralElement', + lockObject: 'lockObject', + unlockObject: 'unlockObject', + relaodSharedElements: 'courseware-structural-elements-released/loadAll' + }), + getElementUrl(element) { + return STUDIP.URLHelper.base_url + 'dispatch.php/contents/courseware/courseware#/structural_element/' + element.id; + }, + updateReadApproval(approval) { + this.selectedElement.attributes['read-approval'] = approval; + }, + updateWriteApproval(approval) { + this.selectedElement.attributes['write-approval'] = approval; + }, + displayEditReleases(element) { + this.selectedElement = element; + this.showEditReleases = true; + }, + async storeReleases() { + const currentId = this.selectedElement.id; + await this.lockObject({ id: currentId, type: 'courseware-structural-elements' }); + await this.updateStructuralElement({ + element: this.selectedElement, + id: currentId, + }); + await this.unlockObject({ id: currentId, type: 'courseware-structural-elements' }); + this.closeEditReleases(); + }, + closeEditReleases() { + this.showEditReleases = false; + this.selectedElement = null; + }, + displayClearReleases(element) { + this.selectedElement = element; + this.showClearReleases = true; + }, + async clearReleases() { + const currentId = this.selectedElement.id; + this.selectedElement.attributes['read-approval'].users = []; + this.selectedElement.attributes['write-approval'].users = []; + await this.lockObject({ id: currentId, type: 'courseware-structural-elements' }); + await this.updateStructuralElement({ + element: this.selectedElement, + id: currentId, + }); + await this.unlockObject({ id: currentId, type: 'courseware-structural-elements' }); + this.closeClearReleases(); + this.relaodSharedElements(); + }, + closeClearReleases() { + this.showClearReleases = false; + this.selectedElement = null; + }, + }, +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index 55dd41637d8..bdd01965f3c 100644 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -179,7 +179,7 @@ :closeText="textEdit.close" closeClass="cancel" height="500" - width="500" + :width="inContent ? '720' : '500'" class="studip-dialog-with-tab" @close="closeEditDialog" @confirm="storeCurrentElement" @@ -305,6 +305,12 @@ @updateReadApproval="updateReadApproval" @updateWriteApproval="updateWriteApproval" /> + <courseware-content-permissions + v-if="inContent" + :element="currentElement" + @updateReadApproval="updateReadApproval" + @updateWriteApproval="updateWriteApproval" + /> </courseware-tab> <courseware-tab v-if="inCourse" :name="textEdit.visible" :index="4"> <form class="default" @submit.prevent=""> @@ -599,6 +605,7 @@ import ContainerComponents from './container-components.js'; import CoursewarePluginComponents from './plugin-components.js'; import CoursewareStructuralElementPermissions from './CoursewareStructuralElementPermissions.vue'; +import CoursewareContentPermissions from './CoursewareContentPermissions.vue'; import CoursewareStructuralElementDiscussion from './CoursewareStructuralElementDiscussion.vue'; import CoursewareAccordionContainer from './CoursewareAccordionContainer.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; @@ -623,6 +630,7 @@ export default { components: { CoursewareStructuralElementDiscussion, CoursewareStructuralElementPermissions, + CoursewareContentPermissions, CoursewareRibbon, CoursewareListContainer, CoursewareAccordionContainer, @@ -758,6 +766,11 @@ export default { return this.$store.getters.context.type === 'courses'; }, + inContent() { + // The rights tab in contents will be only visible to the owner. + return this.$store.getters.context.type === 'users' && this.userId === this.currentElement.relationships.user.data.id; + }, + textDelete() { let textDelete = {}; textDelete.title = this.$gettext('Seite unwiderruflich löschen'); @@ -790,6 +803,11 @@ export default { valid = true; } } + if (context.type === 'sharedusers') { + if (context.id === this.courseware.relationships.root.data.id) { + valid = true; + } + } if (context.type === 'public') { valid = true; @@ -925,11 +943,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: 6, label: this.$gettext('Öffentlichen Link erzeugen'), icon: 'group', emit: 'linkElement' }); + menu.push({ id: 7, label: this.$gettext('Öffentlichen Link erzeugen'), icon: 'group', emit: 'linkElement' }); } if (!this.isRoot && this.canEdit && !this.isTask) { menu.push({ - id: 9, + id: 8, label: this.$gettext('Seite löschen'), icon: 'trash', emit: 'deleteCurrentElement', diff --git a/resources/vue/components/courseware/CoursewareTreeItem.vue b/resources/vue/components/courseware/CoursewareTreeItem.vue index b372b17cc4a..4e359778a7e 100644 --- a/resources/vue/components/courseware/CoursewareTreeItem.vue +++ b/resources/vue/components/courseware/CoursewareTreeItem.vue @@ -112,6 +112,9 @@ export default { return writeApproval.all || writeApproval.groups.length > 0 || writeApproval.users.length > 0; }, hasNoReadApproval() { + if (this.context.type === 'users') { + return false; + } const readApproval = this.element.attributes['read-approval']; if (Object.keys(readApproval).length === 0 || this.hasWriteApproval) { diff --git a/resources/vue/courseware-content-overview-app.js b/resources/vue/courseware-content-overview-app.js index e1f15ef2450..ccacce86174 100644 --- a/resources/vue/courseware-content-overview-app.js +++ b/resources/vue/courseware-content-overview-app.js @@ -37,6 +37,7 @@ const mountApp = (STUDIP, createApp, element) => { 'courseware-containers', 'courseware-instances', 'courseware-structural-elements', + 'courseware-structural-elements-shared', 'courseware-templates', 'courseware-user-data-fields', 'courseware-user-progresses', @@ -84,6 +85,8 @@ const mountApp = (STUDIP, createApp, element) => { type: entry_type, }); + store.dispatch('courseware-structural-elements-shared/loadAll', { options: { include: 'owner' } }); + const app = createApp({ render: (h) => h(ContentOverviewApp), store diff --git a/resources/vue/courseware-content-releases-app.js b/resources/vue/courseware-content-releases-app.js index f99744b34a8..79d56cced2a 100644 --- a/resources/vue/courseware-content-releases-app.js +++ b/resources/vue/courseware-content-releases-app.js @@ -23,6 +23,7 @@ const mountApp = (STUDIP, createApp, element) => { 'courseware-containers', 'courseware-public-links', 'courseware-structural-elements', + 'courseware-structural-elements-released', 'file-refs', 'users', ], @@ -46,6 +47,7 @@ const mountApp = (STUDIP, createApp, element) => { } } + store.dispatch('setUserId', STUDIP.USER_ID); store.dispatch('coursewareContext', { id: entry_id, type: entry_type, @@ -56,6 +58,7 @@ const mountApp = (STUDIP, createApp, element) => { include: 'structural-element', }, }); + store.dispatch('courseware-structural-elements-released/loadAll', {}); const app = createApp({ render: (h) => h(ContentReleasesApp), @@ -67,4 +70,4 @@ const mountApp = (STUDIP, createApp, element) => { return app; } -export default mountApp; \ No newline at end of file +export default mountApp; diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index f8387604d60..0c4811e28c3 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -50,7 +50,9 @@ const getDefaultState = () => { exportState: '', exportProgress: 0, + permissionFilter: 'read', purposeFilter: 'all', + sourceFilter: 'all', showOverviewElementAddDialog: false, bookmarkFilter: 'all', @@ -201,9 +203,15 @@ const getters = { exportProgress(state) { return state.exportProgress; }, + permissionFilter(state) { + return state.permissionFilter; + }, purposeFilter(state) { return state.purposeFilter; }, + sourceFilter(state) { + return state.sourceFilter; + }, bookmarkFilter(state) { return state.bookmarkFilter; }, @@ -1226,9 +1234,15 @@ export const actions = { await dispatch('courseware-task-feedback/delete', data, { root: true }); }, + setPermissionFilter({ commit }, permission) { + commit('setPermissionFilter', permission); + }, setPurposeFilter({ commit }, purpose) { commit('setPurposeFilter', purpose); }, + setSourceFilter({ commit }, source) { + commit('setSourceFilter', source); + }, setBookmarkFilter({ commit }, course) { commit('setBookmarkFilter', course); }, @@ -1413,9 +1427,15 @@ export const mutations = { setExportProgress(state, exportProgress) { state.exportProgress = exportProgress; }, + setPermissionFilter(state, permission) { + state.permissionFilter = permission; + }, setPurposeFilter(state, purpose) { state.purposeFilter = purpose; }, + setSourceFilter(state, source) { + state.sourceFilter = source; + }, setBookmarkFilter(state, course) { state.bookmarkFilter = course; }, -- GitLab