diff --git a/app/controllers/contents/courseware.php b/app/controllers/contents/courseware.php index a91569c7b8355c76e83cd9ab40833c1c8fd8b4c8..6ee2b7867075328c07a1d30fc0c260704ab54706 100644 --- a/app/controllers/contents/courseware.php +++ b/app/controllers/contents/courseware.php @@ -1,8 +1,11 @@ <?php -use \Courseware\StructuralElement; +require_once __DIR__.'/../courseware_controller.php'; -class Contents_CoursewareController extends AuthenticatedController +use Courseware\StructuralElement; +use Courseware\Unit; + +class Contents_CoursewareController extends CoursewareController { /** * Callback function being called before an action is executed. @@ -10,7 +13,7 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.CamelCaseMethodName) * @SuppressWarnings(PHPMD.Superglobals) */ - public function before_filter(&$action, &$args) + public function before_filter(&$action, &$args): void { parent::before_filter($action, $args); @@ -30,34 +33,20 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function index_action() + public function index_action(): void { - Navigation::activateItem('/contents/courseware/overview'); + Navigation::activateItem('/contents/courseware/shelf'); $this->user_id = $GLOBALS['user']->id; - $this->setOverviewSidebar(); - $this->courseware_root = \Courseware\StructuralElement::getCoursewareUser($this->user_id); - if (!$this->courseware_root) { - // create initial courseware dataset - $new = \Courseware\StructuralElement::createEmptyCourseware($this->user_id, 'user'); - $this->courseware_root = $new->getRoot(); - } - $this->licenses = $this->getLicences(); + $this->setShelfSidebar(); + + $this->licenses = $this->getLicenses(); } - private function setOverviewSidebar() + private function setShelfSidebar(): void { $sidebar = Sidebar::Get(); - $views = new TemplateWidget( - _('Aktionen'), - $this->get_template_factory()->open('contents/courseware/overview_action_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-overview-filter-widget'); - - $views = new TemplateWidget( - _('Filter'), - $this->get_template_factory()->open('contents/courseware/overview_filter_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-overview-filter-widget'); + $sidebar->addWidget(new VueWidget('courseware-action-widget')); + $sidebar->addWidget(new VueWidget('courseware-import-widget')); } /** @@ -69,90 +58,29 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function courseware_action($action = false, $widgetId = null) + public function courseware_action($unit_id = null): void { global $perm, $user; - Navigation::activateItem('/contents/courseware/courseware'); $this->user_id = $user->id; - + /** @var array<mixed> $last */ $last = UserConfig::get($this->user_id)->getValue('COURSEWARE_LAST_ELEMENT'); - if (!empty($last[$this->user_id])) { - $this->entry_element_id = $last['global']; - $struct = \Courseware\StructuralElement::findOneBySQL( - "id = ? AND range_id = ? AND range_type = 'user'", - [$this->entry_element_id, $this->user_id] - ); - } - - // load courseware for current user - if (!$this->entry_element_id || !$struct || !$struct->canRead($user)) { - - if (!$user->courseware) { - // create initial courseware dataset - $struct = StructuralElement::createEmptyCourseware($this->user_id, 'user'); - } - - $this->entry_element_id = $user->courseware->id; - } - - $last[$this->user_id] = $this->entry_element_id; - UserConfig::get($this->user_id)->store('COURSEWARE_LAST_ELEMENT', $last); + if ($unit_id === null) { + $this->redirectToFirstUnit('user', $this->user_id, $last); - $this->licenses = $this->getLicences(); - - $this->oer_enabled = Config::get()->OERCAMPUS_ENABLED && $perm->have_perm(Config::get()->OER_PUBLIC_STATUS); - - // Make sure struct has value., to evaluate the export (edit) capability. - if (!isset($struct)) { - $struct = \Courseware\StructuralElement::findOneBySQL( - "id = ? AND range_id = ? AND range_type = 'user'", - [$this->entry_element_id, $this->user_id] - ); + return; } - $this->setCoursewareSidebar(); - } - - private function setCoursewareSidebar() - { - $sidebar = \Sidebar::Get(); - $sidebar->addWidget(new VueWidget('courseware-action-widget')); - $views = new TemplateWidget( - _('Suche'), - $this->get_template_factory()->open('course/courseware/search_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-search-widget'); - - $sidebar->addWidget(new VueWidget('courseware-view-widget')); - $sidebar->addWidget(new VueWidget('courseware-export-widget')); - } - - private function getLicences() - { - $licenses = array(); - $sorm_licenses = License::findBySQL("1 ORDER BY name ASC"); - foreach($sorm_licenses as $license) { - array_push($licenses, $license->toArray()); + $this->entry_element_id = null; + $this->unit_id = null; + $unit = Unit::find($unit_id); + if (isset($unit)) { + $this->setEntryElement('user', $unit, $last, $this->user_id); + Navigation::activateItem('/contents/courseware/courseware'); + $this->licenses = $this->getLicenses(); + $this->setCoursewareSidebar(); } - return json_encode($licenses); - } - - /** - * displays the courseware manager - * - * @param string $action - * @param string $widgetId - * @SuppressWarnings(PHPMD.CamelCaseMethodName) - * @SuppressWarnings(PHPMD.Superglobals) - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - */ - public function courseware_manager_action($action = false, $widgetId = null) - { - Navigation::activateItem('/contents/courseware/courseware_manager'); - - $this->user_id = $GLOBALS['user']->id; } /** @@ -165,7 +93,7 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function bookmarks_action() + public function bookmarks_action(): void { Navigation::activateItem('/contents/courseware/bookmarks'); $this->user_id = $GLOBALS['user']->id; @@ -180,13 +108,13 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function releases_action() + public function releases_action(): void { Navigation::activateItem('/contents/courseware/releases'); $this->user_id = $GLOBALS['user']->id; } - private function setBookmarkSidebar() + private function setBookmarkSidebar(): void { $sidebar = Sidebar::Get(); $views = new TemplateWidget( @@ -205,7 +133,7 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function courses_overview_action($action = false, $widgetId = null) + public function courses_overview_action($action = false, $widgetId = null): void { Navigation::activateItem('/contents/courseware/courses_overview'); @@ -244,7 +172,7 @@ class Contents_CoursewareController extends AuthenticatedController * * @return array */ - private function getCoursewareCourses($sem_key) + private function getCoursewareCourses($sem_key): array { $this->current_semester = Semester::findCurrent(); @@ -326,7 +254,7 @@ class Contents_CoursewareController extends AuthenticatedController * @param string $course_id the course to check * @return boolean true if courseware is enabled, false otherwise */ - private function isCoursewareEnabled($course_id) + private function isCoursewareEnabled($course_id): bool { $studip_module = PluginManager::getInstance()->getPlugin('CoursewareModule'); @@ -338,7 +266,7 @@ class Contents_CoursewareController extends AuthenticatedController } - private function getProjects($purpose) + private function getProjects($purpose): array { $elements = StructuralElement::findProjects($this->user->id, $purpose); foreach($elements as &$element) { @@ -348,85 +276,8 @@ class Contents_CoursewareController extends AuthenticatedController return $elements; } - public function create_project_action($action = false, $widgetId = null) - { - PageLayout::setTitle(_('Neues Lernmaterial')); - - if (!Request::submitted('create_project')) { - return; - } - - CSRFProtection::verifyUnsafeRequest(); - $this->user_id = $GLOBALS['user']->id; - - $structural_element = new StructuralElement(); - $structural_element->title = Request::get('title'); - $structural_element->purpose = Request::get('project_type'); - $structural_element->owner_id = $this->user_id; - $structural_element->editor_id = $this->user_id; - $structural_element->release_date = ""; - $structural_element->withdraw_date = ""; - $structural_element->range_id = $this->user_id; - $structural_element->range_type = 'user'; - $structural_element->parent_id = StructuralElement::getCoursewareUser($this->user_id)->id; - $structural_element->payload = json_encode([ - 'description' => Request::get('description'), - 'color' => Request::get('color'), - 'required_time' => Request::get('required_time'), - 'license_type' => Request::get('license_type'), - 'difficulty_start' => Request::get('difficulty_start'), - 'difficulty_end' => Request::get('difficulty_end'), - ]); - $structural_element->store(); - - // set image - if ($_FILES['previewfile'] && $_FILES['previewfile']['name']) { - $coursewareInstance = new Courseware\Instance($structural_element); - $publicFolder = Courseware\Filesystem\PublicFolder::findOrCreateTopFolder($coursewareInstance); - $fileRef = $this->handleUpload($publicFolder, $structural_element); - $structural_element->image_id = $fileRef->id; - $structural_element->store(); - } - - $this->redirect('contents/courseware/index'); - } - - private function handleUpload(Courseware\Filesystem\PublicFolder $folder, StructuralElement $structuralElement) - { - $file = $_FILES['previewfile']; - $upload = [ - 'tmp_name' => [$file['tmp_name']], - 'name' => [$file['name']], - 'size' => [$file['size']], - 'type' => [$file['type']], - 'error' => [$file['error']] - ]; - - $uploaded = FileManager::handleFileUpload( - $upload, - $folder - ); - - if ($uploaded['error']) { - throw new RuntimeException(implode("\n", $uploaded['error'])); - } - - if (count($uploaded['files'])) { - return $uploaded['files'][0]; - } - - throw new RuntimeException('Could not create preview image.'); - } - - private function setProjectsSidebar($action) - { - $sidebar = Sidebar::Get(); - $actions = new ActionsWidget(); - $actions->addLink(_('Neues Lernmaterial anlegen'), $this->url_for('contents/courseware/create_project'), Icon::create('add', 'clickable'))->asDialog('size=700'); - $sidebar->addWidget($actions); - } - public function pdf_export_action($element_id, $with_children) + public function pdf_export_action($element_id, $with_children): void { $element = \Courseware\StructuralElement::findOneById($element_id); @@ -438,7 +289,7 @@ class Contents_CoursewareController extends AuthenticatedController * * @param string $entry_element_id the shared struct element id */ - public function shared_content_courseware_action($entry_element_id) + public function shared_content_courseware_action($entry_element_id): void { global $perm, $user; @@ -463,7 +314,7 @@ class Contents_CoursewareController extends AuthenticatedController $this->user_id = $struct->owner_id; - $this->licenses = $this->getLicences(); + $this->licenses = $this->getLicenses(); $this->oer_enabled = Config::get()->OERCAMPUS_ENABLED && $perm->have_perm(Config::get()->OER_PUBLIC_STATUS); diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index 3b4a42a39e6c6028e23b673c66cae3ab85e92915..557798708a22793b3a073c5522d4af428c9bad40 100644 --- a/app/controllers/course/courseware.php +++ b/app/controllers/course/courseware.php @@ -1,8 +1,9 @@ <?php +require_once __DIR__.'/../courseware_controller.php'; + use Courseware\StructuralElement; -use Courseware\Instance; -use Courseware\UserProgress; +use Courseware\Unit; /** * @property ?string $entry_element_id @@ -11,11 +12,11 @@ use Courseware\UserProgress; * @property mixed $courseware_progress_data * @property mixed $courseware_chapter_counter */ -class Course_CoursewareController extends AuthenticatedController +class Course_CoursewareController extends CoursewareController { protected $_autobind = true; - public function before_filter(&$action, &$args) + public function before_filter(&$action, &$args): void { parent::before_filter($action, $args); @@ -31,76 +32,56 @@ class Course_CoursewareController extends AuthenticatedController $this->last_visitdate = object_get_visit(Context::getId(), $this->studip_module->getPluginId()); } - public function index_action() + public function index_action(): void { - /** @var array<mixed> $last */ - $last = UserConfig::get($GLOBALS['user']->id)->getValue('COURSEWARE_LAST_ELEMENT'); - if (isset($last[Context::getId()])) { - $this->entry_element_id = $last[Context::getId()]; - /** @var ?StructuralElement $struct */ - $struct = StructuralElement::findOneBySQL("id = ? AND range_id = ? AND range_type = 'course'", [ - $this->entry_element_id, - Context::getId(), - ]); - } - - // load courseware for course - if (!$this->entry_element_id || !$struct || !$struct->canRead($GLOBALS['user'])) { - $course = Course::find(Context::getId()); + Navigation::activateItem('course/courseware/shelf'); + $this->licenses = $this->getLicenses(); + $this->setIndexSidebar(); + } - if (!$course->courseware) { - // create initial courseware dataset - $instance = StructuralElement::createEmptyCourseware(Context::getId(), 'course'); - $struct = $instance->getRoot(); - } + public function courseware_action($unit_id = null): void + { + global $perm, $user; - $this->entry_element_id = $course->courseware->id; - } + $this->user_id = $user->id; + /** @var array<mixed> $last */ + $last = UserConfig::get($this->user_id)->getValue('COURSEWARE_LAST_ELEMENT'); - $last[Context::getId()] = $this->entry_element_id; - UserConfig::get($GLOBALS['user']->id)->store('COURSEWARE_LAST_ELEMENT', $last); + if ($unit_id === null) { + $this->redirectToFirstUnit('course', Context::getId(), $last); - Navigation::activateItem('course/courseware/content'); - $this->licenses = []; - $sorm_licenses = License::findBySQL('1 ORDER BY name ASC'); - foreach ($sorm_licenses as $license) { - array_push($this->licenses, $license->toArray()); + return; } - $this->licenses = json_encode($this->licenses); - // Make sure struct has value., to evaluate the export (edit) capability. - if (!isset($struct)) { - $struct = StructuralElement::findOneBySQL("id = ? AND range_id = ? AND range_type = 'course'", [ - $this->entry_element_id, - Context::getId(), - ]); + $this->entry_element_id = null; + $this->unit_id = null; + $unit = Unit::find($unit_id); + if (isset($unit)) { + $this->setEntryElement('course', $unit, $last, Context::getId()); + + Navigation::activateItem('course/courseware/unit'); + $this->licenses = $this->getLicenses(); + $this->setCoursewareSidebar(); } - $this->setIndexSidebar(); } - public function dashboard_action(): void + public function tasks_action(): void { global $perm, $user; $this->is_teacher = $perm->have_studip_perm('tutor', Context::getId(), $user->id); - $this->courseware_progress_data = $this->getProgressData($this->is_teacher); - $this->courseware_chapter_counter = $this->getChapterCounter($this->courseware_progress_data); - Navigation::activateItem('course/courseware/dashboard'); - $this->setDashboardSidebar(); + Navigation::activateItem('course/courseware/tasks'); + $this->setTasksSidebar(); } - public function manager_action(): void + public function activities_action(): void { - $courseId = Context::getId(); - $element = StructuralElement::getCoursewareCourse($courseId); - $instance = new Instance($element); - if (!$GLOBALS['perm']->have_studip_perm($instance->getEditingPermissionLevel(), $courseId)) { - $this->redirect('course/courseware/index'); - } else { - Navigation::activateItem('course/courseware/manager'); - } + global $perm, $user; + $this->is_teacher = $perm->have_studip_perm('tutor', Context::getId(), $user->id); + Navigation::activateItem('course/courseware/activities'); + $this->setActivitiesSidebar(); } - public function pdf_export_action($element_id, $with_children) + public function pdf_export_action($element_id, $with_children): void { $element = \Courseware\StructuralElement::findOneById($element_id); $user = User::find($GLOBALS['user']->id); @@ -111,205 +92,19 @@ class Course_CoursewareController extends AuthenticatedController { $sidebar = Sidebar::Get(); $sidebar->addWidget(new VueWidget('courseware-action-widget')); - - $views = new TemplateWidget( - _('Suche'), - $this->get_template_factory()->open('course/courseware/search_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-search-widget'); - - $sidebar->addWidget(new VueWidget('courseware-view-widget')); - $sidebar->addWidget(new VueWidget('courseware-export-widget')); + $sidebar->addWidget(new VueWidget('courseware-import-widget')); } - private function setDashboardSidebar(): void + private function setTasksSidebar(): void { $sidebar = Sidebar::Get(); - $views = new TemplateWidget( - _('Ansichten'), - $this->get_template_factory()->open('course/courseware/dashboard_view_widget') - ); - $sidebar->addWidget($views)->addLayoutCSSClass('courseware-dashboard-view-widget'); - } - - private function getProgressData(bool $showProgressForAllParticipants = false): iterable - { - /** @var ?\Course $course */ - $course = Context::get(); - if (!$course || !$course->courseware) { - return []; - } - - $instance = new Instance($course->courseware); - $user = \User::findCurrent(); - - $elements = $this->findElements($instance, $user); - $progress = $this->computeSelfProgresses($instance, $user, $elements, $showProgressForAllParticipants); - $progress = $this->computeCumulativeProgresses($instance, $elements, $progress); - - return $this->prepareProgressData($elements, $progress); - } - - private function findElements(Instance $instance, User $user): iterable - { - $elements = $instance->getRoot()->findDescendants($user); - $elements[] = $instance->getRoot(); - - return array_combine(array_column($elements, 'id'), $elements); - } - - private function computeChildrenOf(iterable &$elements): iterable - { - $childrenOf = []; - foreach ($elements as $elementId => $element) { - if ($element['parent_id']) { - if (!isset($childrenOf[$element['parent_id']])) { - $childrenOf[$element['parent_id']] = []; - } - $childrenOf[$element['parent_id']][] = $elementId; - } - } - - return $childrenOf; - } - - private function computeSelfProgresses( - Instance $instance, - User $user, - iterable &$elements, - bool $showProgressForAllParticipants - ): iterable { - $progress = []; - /** @var \Course $course */ - $course = $instance->getRange(); - $allBlockIds = $instance->findAllBlocksGroupedByStructuralElementId(function ($row) { - return $row['id']; - }); - $courseMemberIds = $showProgressForAllParticipants - ? array_column($course->getMembersWithStatus('autor'), 'user_id') - : [$user->getId()]; - - $sql = - 'SELECT block_id, COUNT(grade) as count, SUM(grade) as grade ' . - 'FROM cw_user_progresses ' . - 'WHERE block_id IN (?) AND user_id IN (?) ' . - 'GROUP BY block_id'; - $userProgresses = \DBManager::get()->fetchGrouped($sql, [$allBlockIds, $courseMemberIds]); - - foreach ($elements as $elementId => $element) { - $selfProgress = $this->getSelfProgresses($allBlockIds, $elementId, $userProgresses, $courseMemberIds); - $progress[$elementId] = [ - 'self' => $selfProgress['counter'] ? $selfProgress['progress'] / $selfProgress['counter'] : 1, - ]; - } - - return $progress; - } - - private function getSelfProgresses( - array &$allBlockIds, - string $elementId, - array &$userProgresses, - array &$courseMemberIds - ): array { - $blks = $allBlockIds[$elementId] ?: []; - if (!count($blks)) { - return [ - 'counter' => 0, - 'progress' => 1, - ]; - } - - $data = [ - 'counter' => count($blks), - 'progress' => 0, - ]; - - $usersCounter = count($courseMemberIds); - foreach ($blks as $blk) { - $progresses = $userProgresses[$blk]; - $usersProgress = $progresses['count'] ? (float) $progresses['grade'] : 0; - $data['progress'] += $usersCounter ? $usersProgress / $usersCounter : 0; - } - - return $data; - } - - private function computeCumulativeProgresses(Instance $instance, iterable &$elements, iterable &$progress): iterable - { - $childrenOf = $this->computeChildrenOf($elements); - - // compute `cumulative` of each element - $visitor = function (&$progress, $element) use (&$childrenOf, &$elements, &$visitor) { - $elementId = $element->getId(); - $numberOfNodes = 0; - $cumulative = 0; - - // visit children first - if (isset($childrenOf[$elementId])) { - foreach ($childrenOf[$elementId] as $childId) { - $visitor($progress, $elements[$childId]); - $numberOfNodes += $progress[$childId]['numberOfNodes']; - $cumulative += $progress[$childId]['cumulative']; - } - } - - $progress[$elementId]['cumulative'] = $cumulative + $progress[$elementId]['self']; - $progress[$elementId]['numberOfNodes'] = $numberOfNodes + 1; - - return $progress; - }; - - $visitor($progress, $instance->getRoot()); - - return $progress; - } - - private function prepareProgressData(iterable &$elements, iterable &$progress): iterable - { - $data = []; - foreach ($elements as $elementId => $element) { - $elementProgress = $progress[$elementId]; - $cumulative = $elementProgress['cumulative'] / $elementProgress['numberOfNodes']; - - $data[$elementId] = [ - 'id' => (int) $elementId, - 'parent_id' => (int) $element['parent_id'], - 'name' => $element['title'], - 'progress' => [ - 'cumulative' => round($cumulative, 2) * 100, - 'self' => round($elementProgress['self'], 2) * 100, - ], - ]; - } - - return $data; + $sidebar->addWidget(new VueWidget('courseware-action-widget')); } - private function getChapterCounter(array &$chapters): array + private function setActivitiesSidebar(): void { - $finished = 0; - $started = 0; - $ahead = 0; - - foreach ($chapters as $chapter) { - if ($chapter['parent_id'] != null) { - if ($chapter['progress']['self'] == 0) { - $ahead += 1; - } - if ($chapter['progress']['self'] > 0 && $chapter['progress']['self'] < 100) { - $started += 1; - } - if ($chapter['progress']['self'] == 100) { - $finished += 1; - } - } - } - - return [ - 'started' => $started, - 'finished' => $finished, - 'ahead' => $ahead, - ]; + $sidebar = Sidebar::Get(); + $sidebar->addWidget(new VueWidget('courseware-activities-widget-filter-type')); + $sidebar->addWidget(new VueWidget('courseware-activities-widget-filter-unit')); } } diff --git a/app/controllers/courseware_controller.php b/app/controllers/courseware_controller.php new file mode 100644 index 0000000000000000000000000000000000000000..30fec908f28cd42c522ed8976da1c2d54a918c16 --- /dev/null +++ b/app/controllers/courseware_controller.php @@ -0,0 +1,76 @@ +<?php + +use Courseware\StructuralElement; +use Courseware\Unit; + +abstract class CoursewareController extends AuthenticatedController +{ + public function redirectToFirstUnit(string $context, string $rangeId, array $last): void + { + $path = $context === 'user' ? 'contents' : $context; + $last_element = $this->getLastElement($last, $context, $rangeId); + if ($last_element) { + $unit = $last_element->findUnit($last); + } else { + $unit = Unit::findOneBySql('range_id = ? ORDER BY mkdate ASC', [$rangeId]); + } + $this->redirect($path . '/courseware/courseware/' . $unit->id); + } + + public function setEntryElement(string $context, Unit $unit, array $last, string $rangeId): void + { + $this->unit_id = $unit->id; + $last_element = $this->getLastElement($last, $context, $rangeId); + if($last_element) { + $last_element_unit = $last_element->findUnit(); + } + if ($last_element_unit->id === $unit->id) { + $this->entry_element_id = $last_element->id; + } else { + $this->entry_element_id = $unit->structural_element_id; + } + if ($this->entry_element_id) { + $last_element_item = $context === 'user' ? 'global' : $rangeId; + $last[$last_element_item] = $this->entry_element_id; + UserConfig::get($GLOBALS['user']->id)->store('COURSEWARE_LAST_ELEMENT', $last); + } + } + + public function getLastElement(array $last, string $context, string $rangeId): ?StructuralElement + { + $last_element_item = $context === 'user' ? 'global' : $rangeId; + $last_element_id = $last[$last_element_item] ?? false; + + if ($last_element_id) { + return StructuralElement::findOneBySQL("id = ? AND range_id = ? AND range_type = ?", [ + $last_element_id, + $rangeId, + $context + ]); + } + + return null; + } + + public function getLicenses(): string + { + $licenses = License::findAndMapBySQL( + function (License $license) { + return $license->toArray(); + }, + '1 ORDER BY name ASC' + ); + + return json_encode($licenses); + } + + public function setCoursewareSidebar(): void + { + $sidebar = \Sidebar::Get(); + $sidebar->addWidget(new VueWidget('courseware-action-widget')); + $sidebar->addWidget(new VueWidget('courseware-search-widget')); + $sidebar->addWidget(new VueWidget('courseware-view-widget')); + $sidebar->addWidget(new VueWidget('courseware-import-widget')); + $sidebar->addWidget(new VueWidget('courseware-export-widget')); + } +} \ No newline at end of file diff --git a/app/views/contents/courseware/bookmarks.php b/app/views/contents/courseware/bookmarks.php index a08032097078fce4f4b9a50ef69512c0a9f4860d..988c692ebfa31bef70a27367cea560bfeeaa32d4 100644 --- a/app/views/contents/courseware/bookmarks.php +++ b/app/views/contents/courseware/bookmarks.php @@ -1,6 +1,6 @@ <div id="courseware-content-bookmark-app" entry-type="users" - entry-id="<?= $user_id ?>" + entry-id="<?= htmlReady($user_id) ?>" > </div> diff --git a/app/views/contents/courseware/courseware.php b/app/views/contents/courseware/courseware.php index b50a96370144bbbf4cf8978a10ed934057d382a7..bba4f1cc7ed1a9f9e7fd79fa559bd360bf20abe7 100644 --- a/app/views/contents/courseware/courseware.php +++ b/app/views/contents/courseware/courseware.php @@ -1,8 +1,10 @@ <div id="courseware-index-app" - entry-element-id="<?= $entry_element_id ?>" - entry-type="users" entry-id="<?= $user_id ?>" - oer-enabled='<?= $oer_enabled ?>' - licenses='<?= $licenses ?>' + entry-element-id="<?= htmlReady($entry_element_id) ?>" + entry-type="users" + entry-id="<?= htmlReady($user_id) ?>" + unit-id="<?= htmlReady($unit_id) ?>" + oer-enabled='<?= htmlReady(Config::get()->OERCAMPUS_ENABLED) ?>' + licenses='<?= htmlReady($licenses) ?>' > </div> diff --git a/app/views/contents/courseware/index.php b/app/views/contents/courseware/index.php index c0d761d0dc3a4430530caf3c9d28b3e944ec30ae..b05d7311b69ce706c696081bba1ecfdca9a3d7e8 100644 --- a/app/views/contents/courseware/index.php +++ b/app/views/contents/courseware/index.php @@ -1,10 +1,6 @@ -<script> - STUDIP.COURSEWARE_USERS_ROOT_ID = <?=$courseware_root->id ?> -</script> <div - id="courseware-content-overview-app" + id="courseware-shelf-app" entry-type="users" entry-id="<?= $user_id ?>" licenses='<?= $licenses ?>' -> -</div> +></div> \ No newline at end of file diff --git a/app/views/course/courseware/activities.php b/app/views/course/courseware/activities.php new file mode 100644 index 0000000000000000000000000000000000000000..67726e010773497c3f0a9cd0773662e8001ed29d --- /dev/null +++ b/app/views/course/courseware/activities.php @@ -0,0 +1,6 @@ +<div + id="courseware-activities-app" + entry-type="courses" + entry-id="<?= htmlReady(Context::getId()) ?>" +> +</div> diff --git a/app/views/course/courseware/courseware.php b/app/views/course/courseware/courseware.php new file mode 100644 index 0000000000000000000000000000000000000000..68503b0b029d56f0fc5ba8b6abbce3ee57e94ea5 --- /dev/null +++ b/app/views/course/courseware/courseware.php @@ -0,0 +1,10 @@ +<div + id="courseware-index-app" + entry-element-id="<?= htmlReady($entry_element_id) ?>" + entry-type="courses" + entry-id="<?= htmlReady(Context::getId()) ?>" + unit-id="<?= htmlReady($unit_id) ?>" + oer-enabled="<?= htmlReady(Config::get()->OERCAMPUS_ENABLED) ?>" + licenses='<?= htmlReady($licenses) ?>' + > +</div> \ No newline at end of file diff --git a/app/views/course/courseware/dashboard.php b/app/views/course/courseware/dashboard.php deleted file mode 100644 index 830cc90560d94997b4d4556173caf12d12f284e8..0000000000000000000000000000000000000000 --- a/app/views/course/courseware/dashboard.php +++ /dev/null @@ -1,12 +0,0 @@ -<script> - STUDIP.courseware_progress_data = <?= json_encode($courseware_progress_data);?>; - STUDIP.courseware_chapter_counter = <?= json_encode($courseware_chapter_counter);?>; - STUDIP.is_teacher = <?= json_encode($is_teacher);?>; -</script> - -<div - id="courseware-dashboard-app" - entry-type="courses" - entry-id="<?= Context::getId() ?>" -> -</div> diff --git a/app/views/course/courseware/index.php b/app/views/course/courseware/index.php index 518f765a3c09103c408db98ac63c4edaefd75c54..81296cbb149d1cd1ba7fd01baa2ec1acfe645158 100644 --- a/app/views/course/courseware/index.php +++ b/app/views/course/courseware/index.php @@ -1,9 +1,6 @@ <div - id="courseware-index-app" - entry-element-id="<?= $entry_element_id ?>" + id="courseware-shelf-app" entry-type="courses" entry-id="<?= Context::getId() ?>" - oer-enabled="<?= Config::get()->OERCAMPUS_ENABLED?>" licenses='<?= $licenses ?>' - > -</div> +></div> diff --git a/app/views/course/courseware/tasks.php b/app/views/course/courseware/tasks.php new file mode 100644 index 0000000000000000000000000000000000000000..7ebd70a41664706978f9885df34df463a1cd2087 --- /dev/null +++ b/app/views/course/courseware/tasks.php @@ -0,0 +1,6 @@ +<div + id="courseware-tasks-app" + entry-type="courses" + entry-id="<?= htmlReady(Context::getId()) ?>" +> +</div> diff --git a/db/migrations/5.3.16_create_cw_units_table.php b/db/migrations/5.3.16_create_cw_units_table.php new file mode 100644 index 0000000000000000000000000000000000000000..7b9fac35ff05200ed7f02e0548ec7a7dcfdff4f0 --- /dev/null +++ b/db/migrations/5.3.16_create_cw_units_table.php @@ -0,0 +1,52 @@ +<?php + +class CreateCwUnitsTable extends Migration +{ + public function description() + { + return 'create table for courseware units'; + } + + public function up() + { + $db = DBManager::get(); + + $query = "CREATE TABLE IF NOT EXISTS `cw_units` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `range_id` CHAR(32) COLLATE latin1_bin NULL, + `range_type` ENUM('course', 'user') COLLATE latin1_bin, + `structural_element_id` INT(11) NOT NULL, + `content_type` ENUM('courseware') CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `public` TINYINT(4) NOT NULL DEFAULT '1', + `creator_id` CHAR(32) COLLATE latin1_bin DEFAULT NULL, + `release_date` INT(11) UNSIGNED DEFAULT NULL, + `withdraw_date` INT(11) UNSIGNED NOT NULL, + `mkdate` INT(11) UNSIGNED NOT NULL, + `chdate` INT(11) UNSIGNED NOT NULL, + + PRIMARY KEY (`id`), + INDEX index_range_id (`range_id`), + INDEX index_structural_element_id (`structural_element_id`) + )"; + $db->exec($query); + + //get all courseware root nodes + $query = "SELECT * FROM `cw_structural_elements` WHERE `parent_id` IS NULL"; + $cw_root_nodes = $db->fetchAll($query); + + // create unit for each courseware root node + $insert = $db->prepare( + "INSERT INTO `cw_units` (`range_id`, `range_type`, `structural_element_id`, `content_type`, `public`, `creator_id`) + VALUES (?, ?, ?, 'courseware', true, ?)" + ); + foreach ($cw_root_nodes as $courseware) { + $insert->execute([$courseware['range_id'], $courseware['range_type'], $courseware['id'], $courseware['owner_id']]); + } + } + + public function down() + { + $db = \DBManager::get(); + $db->exec('DROP TABLE IF EXISTS `cw_units`'); + } +} diff --git a/db/migrations/5.3.17_change_cw_config.php b/db/migrations/5.3.17_change_cw_config.php new file mode 100644 index 0000000000000000000000000000000000000000..949ec58e6ee28e07607ea94ff0a2d669d1bf9293 --- /dev/null +++ b/db/migrations/5.3.17_change_cw_config.php @@ -0,0 +1,56 @@ +<?php + +class ChangeCwConfig extends Migration +{ + public function description() + { + return 'change courseware config'; + } + + public function up() + { + $db = DBManager::get(); + $query = "UPDATE `config` SET `value` = '{}', `type` = 'array' WHERE `config`.`field` = 'COURSEWARE_SEQUENTIAL_PROGRESSION'"; + $db->exec($query); + + $query = "UPDATE `config` SET `value` = '{}', `type` = 'array' WHERE `config`.`field` = 'COURSEWARE_EDITING_PERMISSION'"; + $db->exec($query); + + + $update_permission = $db->prepare("UPDATE `config_values` SET `value` = ? WHERE `field` = 'COURSEWARE_EDITING_PERMISSION' AND `range_id` = ?"); + + $find_root = $db->prepare("SELECT * FROM `cw_structural_elements` WHERE `parent_id` IS NULL AND `range_id` = ? "); + + + // get all COURSEWARE_EDITING_PERMISSION + $stmt = $db->prepare("SELECT * FROM `config_values` WHERE `field` = 'COURSEWARE_EDITING_PERMISSION'"); + $stmt->execute(); + $cw_permissions = $stmt->fetchAll(); + + foreach ($cw_permissions as $permission) { + $find_root->execute([$permission['range_id']]); + $root = $find_root->fetchAll(); + $value = json_encode([$root[0]['id'] => $permission['value']], true); + $update_permission->execute([$value, $permission['range_id']]); + } + + $update_progression = $db->prepare("UPDATE `config_values` SET `value` = ? WHERE `field` = 'COURSEWARE_SEQUENTIAL_PROGRESSION' AND `range_id` = ?"); + + // get all COURSEWARE_SEQUENTIAL_PROGRESSION + $stmt = $db->prepare("SELECT * FROM `config_values` WHERE `field` = 'COURSEWARE_SEQUENTIAL_PROGRESSION'"); + $stmt->execute(); + $cw_progressions = $stmt->fetchAll(); + + foreach ($cw_progressions as $progression) { + $find_root->execute([$progression['range_id']]); + $root = $find_root->fetchAll(); + $value = json_encode([$root[0]['id'] => $progression['value']], true); + $update_progression->execute([$value, $progression['range_id']]); + } + } + + public function down() + { + $db = \DBManager::get(); + } +} diff --git a/lib/activities/CoursewareProvider.php b/lib/activities/CoursewareProvider.php index 335f7bb665ca2c4eb43d253a4d4ac3130b5aa013..f7710949f254998581fb2bede47ab110115fb84e 100644 --- a/lib/activities/CoursewareProvider.php +++ b/lib/activities/CoursewareProvider.php @@ -58,104 +58,225 @@ class CoursewareProvider implements ActivityProvider */ public static function postActivity($event, $resource) { - $data = null; - switch ($event) { - case Block::class . 'DidCreate': - /** - * @var \Courseware\Block $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => null, - 'actor_type' => 'user', - 'actor_id' => $resource->owner_id, - 'verb' => 'created', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + $structuralElement = null; + $rangeType = null; + if ($resource instanceof StructuralElement) { + $structuralElement = $resource; + } + if ($resource instanceof Task || + $resource instanceof StructuralElementComment || + $resource instanceof StructuralElementFeedback + ) { + $structuralElement = $resource['structural_element']; + } + if ($resource instanceof Block || + $resource instanceof BlockComment || + $resource instanceof BlockFeedback || + $resource instanceof Container || + $resource instanceof TaskFeedback + ) { + $structuralElement = $resource->getStructuralElement(); + } - case Block::class . 'DidUpdate': - /** - * @var \Courseware\Block $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - $payload = $resource->type->getPayload(); - if ( - (isset($payload['text']) && $payload['text'] != '') || - (isset($payload['content']) && $payload['content'] != '') - ) { + if ($structuralElement !== null) { + $rangeType = $structuralElement->range_type; + } + + if ($rangeType === 'courses' || $rangeType === 'course') { + $data = null; + switch ($event) { + case Block::class . 'DidCreate': + /** + * @var \Courseware\Block $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $blockType = $resource->type; + $blockTitle = $blockType->getTitle() ?: $resource->getBlockType(); + $content = _('Ein Block vom Typ "%1$s" wurde auf der Seite "%2$s" eingefügt.'); + $content = sprintf($content, $blockTitle, $structuralElement->title); $data = [ 'provider' => self::class, 'context' => $structuralElement->range_type, 'context_id' => $structuralElement->range_id, - 'content' => null, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->owner_id, + 'verb' => 'created', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; + + case Block::class . 'DidUpdate': + /** + * @var \Courseware\Block $resource + * @var \Courseware\StructuralElement $structuralElement + */ + if (!$resource->edit_blocker_id) { + $blockType = $resource->type; + $blockTitle = $blockType->getTitle() ?? $resource->getBlockType(); + $content = _('Ein Block vom Typ "%1$s" wurde auf der Seite "%2$s" verändert.'); + $content = sprintf($content, $blockTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->editor_id, + 'verb' => 'edited', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + } + break; + + case Block::class . 'DidDelete': + /** + * @var \Courseware\Block $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $blockType = $resource->type; + $blockTitle = $blockType->getTitle() ?: $resource->getBlockType(); + $content = _('Ein Block vom Typ "%1$s" wurde auf der Seite "%2$s" gelöscht.'); + $content = sprintf($content, $blockTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, 'actor_type' => 'user', 'actor_id' => $resource->editor_id, - 'verb' => 'edited', + 'verb' => 'voided', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; + + case BlockComment::class . 'DidCreate': + /** + * @var \Courseware\BlockComment $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $structuralElement = $resource->getStructuralElement(); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $resource->comment, + 'actor_type' => 'user', + 'actor_id' => $resource->user_id, + 'verb' => 'interacted', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; + + case BlockFeedback::class . 'DidCreate': + /** + * @var \Courseware\BlockFeedback $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $resource->feedback, + 'actor_type' => 'user', + 'actor_id' => $resource->user_id, + 'verb' => 'answered', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; + + case Container::class . 'DidCreate': + /** + * @var \Courseware\Container $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $containerType = $resource->type; + $containerTitle = $containerType->getTitle() ?: _('unbekannt'); + $content = _('Ein Abschnitt vom Typ "%1$s" wurde auf der Seite "%2$s" eingefügt.'); + $content = sprintf($content, $containerTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->owner_id, + 'verb' => 'created', 'object_id' => $structuralElement->id, 'object_type' => 'courseware', 'mkdate' => time(), ]; - } - break; + break; - case BlockComment::class . 'DidCreate': - /** - * @var \Courseware\BlockComment $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => $resource->comment, - 'actor_type' => 'user', - 'actor_id' => $resource->user_id, - 'verb' => 'interacted', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + case Container::class . 'DidUpdate': + /** + * @var \Courseware\Block $resource + * @var \Courseware\StructuralElement $structuralElement + */ + if (!$resource->edit_blocker_id) { + $containerType = $resource->type; + $containerTitle = $containerType->getTitle() ?: _('unbekannt'); + $content = _('Ein Abschnitt vom Typ "%1$s" wurde auf der Seite "%2$s" verändert.'); + $content = sprintf($content, $containerTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->editor_id, + 'verb' => 'edited', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + } + break; - case BlockFeedback::class . 'DidCreate': - /** - * @var \Courseware\BlockFeedback $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => $resource->feedback, - 'actor_type' => 'user', - 'actor_id' => $resource->user_id, - 'verb' => 'answered', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + case Container::class . 'DidDelete': + /** + * @var \Courseware\Container $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $containerType = $resource->type; + $containerTitle = $containerType->getTitle() ?: _('unbekannt'); + $content = _('Ein Abschnitt vom Typ "%1$s" wurde auf der Seite "%2$s" gelöscht.'); + $content = sprintf($content, $containerTitle, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $content, + 'actor_type' => 'user', + 'actor_id' => $resource->editor_id, + 'verb' => 'voided', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; - case StructuralElement::class . 'DidCreate': - /** - * @var \Courseware\StructuralElement $resource - */ - if ($resource->range_type === 'courses') { + case StructuralElement::class . 'DidCreate': + /** + * @var \Courseware\StructuralElement $resource + */ + $content = _('Eine Seite mit dem Titel "%s" wurde angelegt.'); + $content = sprintf($content, $structuralElement->title); $data = [ 'provider' => self::class, 'context' => $resource->range_type, 'context_id' => $resource->range_id, - 'content' => null, + 'content' => $content, 'actor_type' => 'user', 'actor_id' => $resource->owner_id, 'verb' => 'created', @@ -163,56 +284,70 @@ class CoursewareProvider implements ActivityProvider 'object_type' => 'courseware', 'mkdate' => time(), ]; - } - break; + break; + case StructuralElement::class . 'DidDelete': + /** + * @var \Courseware\StructuralElement $resource + */ + $content = _('Eine Seite mit dem Titel "%s" wurde gelöscht.'); + $content = sprintf($content, $structuralElement->title); + $data = [ + 'provider' => self::class, + 'context' => $resource->range_type, + 'context_id' => $resource->range_id, + 'content' => null, + 'actor_type' => 'user', + 'actor_id' => $resource->owner_id, + 'verb' => 'voided', + 'object_id' => $resource->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; - case StructuralElementComment::class . 'DidCreate': - /** - * @var \Courseware\StructuralElementComment $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource['structural_element']; - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => $resource->comment, - 'actor_type' => 'user', - 'actor_id' => $resource->user_id, - 'verb' => 'interacted', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + case StructuralElementComment::class . 'DidCreate': + /** + * @var \Courseware\StructuralElementComment $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $resource->comment, + 'actor_type' => 'user', + 'actor_id' => $resource->user_id, + 'verb' => 'interacted', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; - case StructuralElementFeedback::class . 'DidCreate': - /** - * @var \Courseware\StructuralElementFeedback $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource['structural_element']; - $data = [ - 'provider' => self::class, - 'context' => $structuralElement->range_type, - 'context_id' => $structuralElement->range_id, - 'content' => $resource->feedback, - 'actor_type' => 'user', - 'actor_id' => $resource->user_id, - 'verb' => 'answered', - 'object_id' => $structuralElement->id, - 'object_type' => 'courseware', - 'mkdate' => time(), - ]; - break; + case StructuralElementFeedback::class . 'DidCreate': + /** + * @var \Courseware\StructuralElementFeedback $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $resource->feedback, + 'actor_type' => 'user', + 'actor_id' => $resource->user_id, + 'verb' => 'answered', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + break; - case Task::class . 'DidCreate': - /** - * @var \Courseware\Task $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource['structural_element']; - if ($structuralElement->range_type === 'courses') { + case Task::class . 'DidCreate': + /** + * @var \Courseware\Task $resource + * @var \Courseware\StructuralElement $structuralElement + */ $data = [ 'provider' => self::class, 'context' => $structuralElement->range_type, @@ -220,21 +355,18 @@ class CoursewareProvider implements ActivityProvider 'content' => null, 'actor_type' => 'user', 'actor_id' => $resource->task_group->lecturer_id, - 'verb' => 'set', + 'verb' => 'created', 'object_id' => $structuralElement->id, 'object_type' => 'courseware', 'mkdate' => time(), ]; - } - break; + break; - case TaskFeedback::class . 'DidCreate': - /** - * @var \Courseware\TaskFeedback $resource - * @var \Courseware\StructuralElement $structuralElement - */ - $structuralElement = $resource->getStructuralElement(); - if ($structuralElement->range_type === 'courses') { + case TaskFeedback::class . 'DidCreate': + /** + * @var \Courseware\TaskFeedback $resource + * @var \Courseware\StructuralElement $structuralElement + */ $data = [ 'provider' => self::class, 'context' => $structuralElement->range_type, @@ -247,12 +379,11 @@ class CoursewareProvider implements ActivityProvider 'object_type' => 'courseware', 'mkdate' => time(), ]; - } - break; - } - - if ($data) { - Activity::create($data); + break; + } + if ($data) { + Activity::create($data); + } } } } diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 312a90ab4ef8750eec6df5d4ed31bdfd877217b9..0709ef3a75d82e7c760d22c5b2b59e1b6c9bac92 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -430,6 +430,8 @@ class RouteMap $group->get('/courseware-blocks/{id}/user-progress', Routes\Courseware\UserProgressOfBlocksShow::class); $group->get('/courseware-user-progresses/{id}', Routes\Courseware\UserProgressesShow::class); + // not a JSON route + $group->get('/courseware-units/{id}/courseware-user-progresses', Routes\Courseware\UserProgressesOfUnitsShow::class); $group->patch('/courseware-user-progresses/{id}', Routes\Courseware\UserProgressesUpdate::class); $group->get('/courseware-blocks/{id}/comments', Routes\Courseware\BlockCommentsOfBlocksIndex::class); @@ -468,6 +470,15 @@ class RouteMap $group->post('/courseware-public-links', Routes\Courseware\PublicLinksCreate::class); $group->patch('/courseware-public-links/{id}', Routes\Courseware\PublicLinksUpdate::class); $group->delete('/courseware-public-links/{id}', Routes\Courseware\PublicLinksDelete::class); + + $group->get('/courses/{id}/courseware-units', Routes\Courseware\CoursesUnitsIndex::class); + $group->get('/users/{id}/courseware-units', Routes\Courseware\UsersUnitsIndex::class); + $group->get('/courseware-units/{id}', Routes\Courseware\UnitsShow::class); + $group->post('/courseware-units', Routes\Courseware\UnitsCreate::class); + $group->patch('/courseware-units/{id}', Routes\Courseware\UnitsUpdate::class); + $group->delete('/courseware-units/{id}', Routes\Courseware\UnitsDelete::class); + // not a JSON route + $group->post('/courseware-units/{id}/copy', Routes\Courseware\UnitsCopy::class); } private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void @@ -550,3 +561,4 @@ class RouteMap $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler); } } + diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php index 0331be71a5023dfa52bda67c5372898110a57925..1c7c9034192d7bccdffec29beae3320510db3fbd 100644 --- a/lib/classes/JsonApi/Routes/Courseware/Authority.php +++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php @@ -14,10 +14,12 @@ use Courseware\Task; use Courseware\TaskFeedback; use Courseware\TaskGroup; use Courseware\Template; +use Courseware\Unit; use Courseware\UserDataField; use Courseware\UserProgress; use Courseware\PublicLink; use User; +use Course; /** * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) @@ -69,11 +71,7 @@ class Authority return $structural_element->canEdit($user); } - $perm = $GLOBALS['perm']->have_studip_perm( - $structural_element->course->config->COURSEWARE_EDITING_PERMISSION, - $structural_element->course->id, - $user->id - ); + $perm = $structural_element->hasEditingPermission($user); return $resource->getBlockerUserId() === $user->id || $perm; } @@ -111,11 +109,7 @@ class Authority return $structural_element->canEdit($user); } - $perm = $GLOBALS['perm']->have_studip_perm( - $structural_element->course->config->COURSEWARE_EDITING_PERMISSION, - $structural_element->course->id, - $user->id - ); + $perm = $structural_element->hasEditingPermission($user); return $resource->edit_blocker_id == '' || $resource->edit_blocker_id === $user->id || $perm; } @@ -262,15 +256,7 @@ class Authority public static function canUpdateBlockComment(User $user, BlockComment $resource) { - if ($resource->block->container->structural_element->range_type === 'user') { - return $resource->block->container->structural_element->range_id === $user->id; - } - - $perm = $GLOBALS['perm']->have_studip_perm( - $resource->block->container->structural_element->course->config->COURSEWARE_EDITING_PERMISSION, - $resource->block->container->structural_element->course->id, - $user->id - ); + $perm = $resource->block->container->structural_element->hasEditingPermission($user); return $user->id === $resource->user_id || $perm; } @@ -387,15 +373,7 @@ class Authority return true; } - if ($resource->structural_element->range_type === 'user') { - return $resource->structural_element->range_id === $user->id; - } - - $perm = $GLOBALS['perm']->have_studip_perm( - $resource->structural_element->course->config->COURSEWARE_EDITING_PERMISSION, - $resource->structural_element->course->id, - $user->id - ); + $perm = $resource->structural_element->hasEditingPermission($user); return $user->id == $resource->user_id || $perm; } @@ -416,15 +394,7 @@ class Authority return true; } - if ($resource->range_type === 'user') { - return $resource->range_id === $user->id; - } - - $perm = $GLOBALS['perm']->have_studip_perm( - $resource->course->config->COURSEWARE_EDITING_PERMISSION, - $resource->course->id, - $user->id - ); + $perm = $resource->hasEditingPermission($user); return $perm; } @@ -504,4 +474,39 @@ class Authority return (bool) $publicLink; } + public static function canShowUnit(User $user, Unit $resource): bool + { + return $resource->canRead($user); + } + + public static function canIndexUnits(User $user): bool + { + return $GLOBALS['perm']->have_perm('root', $user->id); + } + + public static function canCreateUnit(User $user): bool + { + return $GLOBALS['perm']->have_perm('tutor', $user->id); + } + + public static function canUpdateUnit(User $user, Unit $resource): bool + { + return $resource->canEdit($user); + } + + public static function canDeleteUnit(User $user, Unit $resource): bool + { + return self::canUpdateUnit($user, $resource); + } + + public static function canIndexUnitsOfACourse(User $user, Course $course): bool + { + return $GLOBALS['perm']->have_studip_perm('user', $course->id, $user->id); + } + + public static function canIndexUnitsOfAUser(User $request_user, User $user): bool + { + return $request_user->id === $user->id; + } + } diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php b/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php new file mode 100644 index 0000000000000000000000000000000000000000..dde67bcb18eb12e55ac84432fe61e7ac1ef83421 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/CoursesUnitsIndex.php @@ -0,0 +1,46 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Unit; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays the course's courseware units. + */ +class CoursesUnitsIndex extends JsonApiController +{ + use CoursewareInstancesHelper; + + protected $allowedIncludePaths = [ + 'structural-element', + 'creator', + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $course = \Course::find($args['id']); + if (!$course) { + throw new RecordNotFoundException(); + } + $user = $this->getUser($request); + if (!Authority::canIndexUnitsOfACourse($user, $course)) { + throw new AuthorizationFailedException(); + } + + $resources = Unit::findCoursesUnits($course); + $total = count($resources); + [$offset, $limit] = $this->getOffsetAndLimit(); + + return $this->getPaginatedContentResponse(array_slice($resources, $offset, $limit), $total); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php index 46a2e689d675c73a028f43c37f43de06e76a60bf..a120f63e6241d7951fbb33b9adc3efaf5db4ec8f 100644 --- a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php +++ b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php @@ -4,6 +4,7 @@ namespace JsonApi\Routes\Courseware; use Courseware\Instance; use Courseware\StructuralElement; +use Courseware\Unit; use JsonApi\Errors\BadRequestException; use JsonApi\Errors\RecordNotFoundException; @@ -31,7 +32,22 @@ trait CoursewareInstancesHelper if (!($method = $methods[$rangeType])) { throw new BadRequestException('Invalid range type: "' . $rangeType . '".'); } - if (!($root = StructuralElement::$method($rangeId))) { + $root = null; + if ($rangeType !== 'sharedusers') { + $chunks = explode('_', $rangeId); + $courseId = $chunks[0]; + $unitId = $chunks[1] ?? null; + + if ($unitId) { + $unit = Unit::findOneBySQL('range_id = ? AND id = ?', [$courseId, $unitId]); + } else { + $unit = Unit::findOneBySQL('range_id = ?', [$courseId]); + } + $root = $unit->structural_element; + } else { + $root = StructuralElement::$method($rangeId); + } + if (!$root) { throw new RecordNotFoundException(); } diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php index 2b70cbf90b605d4f98bdece4714142aa47193e26..e48588413cfe8af382d508ea426f3912c931c1f1 100644 --- a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php +++ b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesUpdate.php @@ -22,7 +22,12 @@ class CoursewareInstancesUpdate extends JsonApiController */ public function __invoke(Request $request, Response $response, $args) { - $resource = $this->findInstance($args['id']); + $chunks = explode('_', $args['id']); + $rangeType = $chunks[0]; + $rangeId = $chunks[1]; + $unitId = $chunks[2] ?? null; + + $resource = $this->findInstanceWithRange($rangeType, $rangeId . '_' . $unitId); $json = $this->validate($request, $resource); if (!Authority::canUpdateCoursewareInstance($user = $this->getUser($request), $resource)) { throw new AuthorizationFailedException(); diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php index 6e14b72d18178cd7ee882f1b713f5026dabb2909..234d8f0c37b06ecd3be5050cc5cb60abcd9c0814 100644 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php @@ -41,6 +41,13 @@ class StructuralElementsCopy extends NonJsonApiController if ($data['remove_purpose']) { $newElement->purpose = ''; } + if (!empty($data['modifications'])) { + $newElement->title = $data['modifications']['title'] ?? $newElement->title; + $newElement->payload['color'] = $data['modifications']['color'] ?? 'studip-blue'; + $newElement->payload['description'] = $data['modifications']['description']; + } + + $newElement->store(); return $this->redirectToStructuralElement($response, $newElement); } diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php index 4aa07166220596bd21c7455589e6c66cd7f1b736..40a96b3e421d7f204ab5ee26c27dbfe68e6c64ea 100644 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php @@ -31,7 +31,8 @@ class StructuralElementsShow extends JsonApiController 'edit-blocker', 'owner', 'parent', - 'target' + 'target', + 'unit', ]; /** diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsCopy.php b/lib/classes/JsonApi/Routes/Courseware/UnitsCopy.php new file mode 100644 index 0000000000000000000000000000000000000000..53654598d522a16eb1bc37dff144f17e3cf36428 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsCopy.php @@ -0,0 +1,46 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use JsonApi\NonJsonApiController; +use Courseware\Unit; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Errors\UnprocessableEntityException; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** +* Copy an courseware unit into a course or users contents +* +* @author Ron Lucke <lucke@elan-ev.de> +* @license GPL2 or any later version +* +* @since Stud.IP 5.3 +*/ + +class UnitsCopy extends NonJsonApiController +{ + public function __invoke(Request $request, Response $response, array $args) + { + $data = $request->getParsedBody()['data']; + + $sourceUnit = Unit::find($args['id']); + $user = $this->getUser($request); + $rangeId = $data['rangeId']; + $rangeType = $data['rangeType']; + $modified = $data['modified']; + + if (!Authority::canCreateUnit($user)) { + throw new AuthorizationFailedException(); + } + + $newUnit = $sourceUnit->copy($user, $rangeId, $rangeType, $modified); + + $response = $response->withHeader('Content-Type', 'application/json'); + $response->getBody()->write((string) json_encode($newUnit)); + + return $response; + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php b/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php new file mode 100644 index 0000000000000000000000000000000000000000..8098bca93f8e8536cd016ab446ff8b9d96cc6f8c --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php @@ -0,0 +1,122 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\Unit as UnitSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; + +/** + * Create a block in a container. + */ +class UnitsCreate extends JsonApiController +{ + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $user = $this->getUser($request); + if (!Authority::canCreateUnit($user)) { + throw new AuthorizationFailedException(); + } + $struct = $this->createUnit($user, $json); + + return $this->getCreatedResponse($struct); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (UnitSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + if (!self::arrayHas($json, 'data.attributes.title')) { + return 'Missing `title` value.'; + } + if (!self::arrayHas($json, 'data.attributes.payload.description')) { + return 'Missing `description` value.'; + } + if (!self::arrayHas($json, 'data.relationships.range')) { + return 'Missing `range` relationship.'; + } + if (!$this->validateRange($json)) { + return 'Invalid `range` relationship.'; + } + } + + private function validateRange($json): bool + { + $rangeData = self::arrayGet($json, 'data.relationships.range.data'); + + if (!in_array($rangeData['type'], ['courses','users'])) { + return false; + } + if ($rangeData['type'] === 'courses') { + $range = \Course::find($rangeData['id']); + } else { + $range = \User::find($rangeData['id']); + } + + return isset($range); + } + + private function createUnit(\User $user, array $json) + { + $range_id = self::arrayGet($json, 'data.relationships.range.data.id'); + $range_type = self::getRangeType(self::arrayGet($json, 'data.relationships.range.data.type')); + + $struct = \Courseware\StructuralElement::build([ + 'parent_id' => null, + 'range_id' => $range_id, + 'range_type' => $range_type, + 'owner_id' => $user->id, + 'editor_id' => $user->id, + 'edit_blocker_id' => '', + 'title' => self::arrayGet($json, 'data.attributes.title', ''), + 'purpose' => self::arrayGet($json, 'data.attributes.purpose', ''), + 'payload' => self::arrayGet($json, 'data.attributes.payload', ''), + 'position' => 0 + ]); + + $struct->store(); + + $unit = \Courseware\Unit::build([ + 'range_id' => $range_id, + 'range_type' => $range_type, + 'structural_element_id' => $struct->id, + 'content_type' => 'courseware', + 'creator_id' => $user->id, + 'public' => self::arrayGet($json, 'data.attributes.public', ''), + 'release_date' => self::arrayGet($json, 'data.attributes.release-date', ''), + 'withdraw_date' => self::arrayGet($json, 'data.attributes.withdraw-date', ''), + ]); + + $unit->store(); + + return $unit; + } + + private function getRangeType($type): ?string + { + $type_map = [ + 'courses' => 'course', + 'users' => 'user', + ]; + + return $type_map[$type] ?? null; + } +} + diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsDelete.php b/lib/classes/JsonApi/Routes/Courseware/UnitsDelete.php new file mode 100644 index 0000000000000000000000000000000000000000..6c9f7088d494d4007a4f0e48afd724f47a1330a4 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsDelete.php @@ -0,0 +1,34 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Unit; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one StructuralElement. + */ +class UnitsDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = Unit::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $user = $this->getUser($request); + if (!Authority::canDeleteUnit($user, $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsIndex.php b/lib/classes/JsonApi/Routes/Courseware/UnitsIndex.php new file mode 100644 index 0000000000000000000000000000000000000000..f9c9376aa537525eef3fb61b7183154d0ec87844 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsIndex.php @@ -0,0 +1,35 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Unit; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays all PublicLinks + */ +class UnitsIndex extends JsonApiController +{ + protected $allowedPagingParameters = ['offset', 'limit']; + + protected $allowedIncludePaths = ['creator', 'structural-element']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $user = $this->getUser($request); + if (!Authority::canIndexUnits($user)) { + throw new AuthorizationFailedException(); + } + + list($offset, $limit) = $this->getOffsetAndLimit(); + $resources = Unit::findBySQL('1 ORDER BY mkdate LIMIT ? OFFSET ?', [$limit, $offset]); + + return $this->getPaginatedContentResponse($resources, count($resources)); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsShow.php b/lib/classes/JsonApi/Routes/Courseware/UnitsShow.php new file mode 100644 index 0000000000000000000000000000000000000000..37dbe3bee020ae7f877c8e920355a3cdfe2ca30b --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsShow.php @@ -0,0 +1,42 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Unit; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Schemas\Courseware\Unit as UnitSchema; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays one Task. + */ +class UnitsShow extends JsonApiController +{ + protected $allowedIncludePaths = [ + UnitSchema::REL_CREATOR, + UnitSchema::REL_STRUCTURAL_ELEMENT + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\Unit $resource */ + $resource = Unit::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowUnit($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..53fccd3358fe46f4b1d31d199f577adb42c5df85 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php @@ -0,0 +1,95 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Unit; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\Unit as UnitSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Update one Block. + */ +class UnitsUpdate extends JsonApiController +{ + use EditBlockAwareTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = Unit::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + $user = $this->getUser($request); + if (!Authority::canUpdateUnit($user, $resource)) { + throw new AuthorizationFailedException(); + } + $resource = $this->updateUnit($user, $resource, $json); + + return $this->getContentResponse($resource); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $resource) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + + if (UnitSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + + if (!self::arrayHas($json, 'data.id')) { + return 'Document must have an `id`.'; + } + + if (self::arrayHas($json, 'data.attributes.release-date')) { + $releaseDate = self::arrayGet($json, 'data.attributes.release-date'); + if (!self::isValidTimestamp($releaseDate)) { + return '`release-date` is not an ISO 8601 timestamp.'; + } + } + + if (self::arrayHas($json, 'data.attributes.withdraw-date')) { + $withdrawDate = self::arrayGet($json, 'data.attributes.withdraw-date'); + if (!self::isValidTimestamp($withdrawDate)) { + return '`withdraw-date` is not an ISO 8601 timestamp.'; + } + } + } + + private function updateUnit(\User $user, Unit $resource, array $json): Unit + { + if (self::arrayHas($json, 'data.attributes.public')) { + $resource->public = self::arrayGet($json, 'data.attributes.public'); + } + + if (self::arrayHas($json, 'data.attributes.release-date')) { + $releaseDate = self::arrayGet($json, 'data.attributes.release-date', ''); + $releaseDate = self::fromISO8601($releaseDate); + $resource->release_date = $releaseDate->getTimestamp(); + } + + if (self::arrayHas($json, 'data.attributes.withdraw-date')) { + $withdrawDate = self::arrayGet($json, 'data.attributes.withdraw-date', ''); + $withdrawDate = self::fromISO8601($withdrawDate); + $resource->withdraw_date = $withdrawDate->getTimestamp(); + } + + $resource->store(); + + return $resource; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/UserProgressesOfUnitsShow.php b/lib/classes/JsonApi/Routes/Courseware/UserProgressesOfUnitsShow.php new file mode 100644 index 0000000000000000000000000000000000000000..48e7c95e47083223bf4373b5b74607db890b30f8 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UserProgressesOfUnitsShow.php @@ -0,0 +1,191 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use JsonApi\NonJsonApiController; +use Courseware\Instance; +use Courseware\StructuralElement; +use Courseware\Unit; +use Courseware\UserProgress; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Errors\UnprocessableEntityException; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays the progress of a user for a unit + * + * @author Ron Lucke <lucke@elan-ev.de> + * @license GPL2 or any later version + * + * @since Stud.IP 5.3 + */ + +class UserProgressesOfUnitsShow extends NonJsonApiController +{ + public function __invoke(Request $request, Response $response, array $args) + { + $user = $this->getUser($request); + $unit = Unit::find($args['id']); + if (!$unit) { + throw new RecordNotFoundException(); + } + $root = $unit->structural_element; + if (!$GLOBALS['perm']->have_studip_perm('autor', $root->range_id) || !$unit->canRead($user)) { + throw new AuthorizationFailedException(); + } + $instance = new Instance($root); + $isTeacher = $GLOBALS['perm']->have_studip_perm('tutor', $root->range_id); + + $elements = $this->findElements($instance, $user); + + $progress = $this->computeSelfProgresses($instance, $user, $elements, $isTeacher); + $progress = $this->computeCumulativeProgresses($instance, $elements, $progress); + + $progresses = $this->prepareProgressData($elements, $progress); + + $response = $response->withHeader('Content-Type', 'application/json'); + $response->getBody()->write((string) json_encode($progresses)); + + return $response; + } + + private function findElements(Instance $instance, \User $user): iterable + { + $elements = $instance->getRoot()->findDescendants($user); + $elements[] = $instance->getRoot(); + + return array_combine(array_column($elements, 'id'), $elements); + } + + private function computeSelfProgresses( + Instance $instance, + \User $user, + iterable &$elements, + bool $showProgressForAllParticipants + ): iterable + { + $progress = []; + /** @var \Course $course */ + $course = $instance->getRange(); + $allBlockIds = $instance->findAllBlocksGroupedByStructuralElementId(function ($row) { + return $row['id']; + }); + $courseMemberIds = $showProgressForAllParticipants + ? array_column($course->getMembersWithStatus('autor'), 'user_id') + : [$user->getId()]; + + $sql = "SELECT block_id, COUNT(grade) AS count, SUM(grade) AS grade + FROM cw_user_progresses + WHERE block_id IN (?) AND user_id IN (?) + GROUP BY block_id"; + + $userProgresses = \DBManager::get()->fetchGrouped($sql, [$allBlockIds, $courseMemberIds]); + + foreach ($elements as $elementId => $element) { + $selfProgress = $this->getSelfProgresses($allBlockIds, $elementId, $userProgresses, $courseMemberIds); + $progress[$elementId] = [ + 'self' => $selfProgress['counter'] ? $selfProgress['progress'] / $selfProgress['counter'] : 1, + ]; + } + + return $progress; + } + + private function getSelfProgresses( + iterable &$allBlockIds, + string $elementId, + array &$userProgresses, + array &$courseMemberIds + ): array { + $blks = $allBlockIds[$elementId] ?? []; + if (count($blks) === 0) { + return [ + 'counter' => 0, + 'progress' => 1, + ]; + } + + $data = [ + 'counter' => count($blks), + 'progress' => 0, + ]; + + $usersCounter = count($courseMemberIds); + foreach ($blks as $blk) { + $progresses = $userProgresses[$blk]; + $usersProgress = $progresses['count'] ? (float) $progresses['sum'] : 0; + $data['progress'] += $usersProgress / $usersCounter; + } + + return $data; + } + + private function computeCumulativeProgresses(Instance $instance, iterable &$elements, iterable &$progress): iterable + { + $childrenOf = $this->computeChildrenOf($elements); + + // compute `cumulative` of each element + $visitor = function (&$progress, $element) use (&$childrenOf, &$elements, &$visitor) { + $elementId = $element->getId(); + $numberOfNodes = 0; + $cumulative = 0; + + // visit children first + if (isset($childrenOf[$elementId])) { + foreach ($childrenOf[$elementId] as $childId) { + $visitor($progress, $elements[$childId]); + $numberOfNodes += $progress[$childId]['numberOfNodes']; + $cumulative += $progress[$childId]['cumulative']; + } + } + + $progress[$elementId]['cumulative'] = $cumulative + $progress[$elementId]['self']; + $progress[$elementId]['numberOfNodes'] = $numberOfNodes + 1; + + return $progress; + }; + + $visitor($progress, $instance->getRoot()); + + return $progress; + } + + private function computeChildrenOf(iterable &$elements): iterable + { + $childrenOf = []; + foreach ($elements as $elementId => $element) { + if ($element['parent_id']) { + if (!isset($childrenOf[$element['parent_id']])) { + $childrenOf[$element['parent_id']] = []; + } + $childrenOf[$element['parent_id']][] = $elementId; + } + } + + return $childrenOf; + } + + private function prepareProgressData(iterable &$elements, iterable &$progress): iterable + { + $data = []; + foreach ($elements as $elementId => $element) { + $elementProgress = $progress[$elementId]; + $cumulative = $elementProgress['cumulative'] / $elementProgress['numberOfNodes']; + + $data[$elementId] = [ + 'id' => (int) $elementId, + 'parent_id' => (int) $element['parent_id'], + 'name' => $element['title'], + 'progress' => [ + 'cumulative' => round($cumulative, 2) * 100, + 'self' => round($elementProgress['self'], 2) * 100, + ], + ]; + } + + return $data; + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/UsersUnitsIndex.php b/lib/classes/JsonApi/Routes/Courseware/UsersUnitsIndex.php new file mode 100644 index 0000000000000000000000000000000000000000..74bcd322db9987cebdf396805795a78a53768236 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UsersUnitsIndex.php @@ -0,0 +1,46 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Unit; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays the course's courseware units. + */ +class UsersUnitsIndex extends JsonApiController +{ + use CoursewareInstancesHelper; + + protected $allowedIncludePaths = [ + 'structural-element', + 'creator', + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $user = \User::find($args['id']); + if (!$user) { + throw new RecordNotFoundException(); + } + $request_user = $this->getUser($request); + if (!Authority::canIndexUnitsOfAUser($request_user, $user)) { + throw new AuthorizationFailedException(); + } + + $resources = Unit::findUsersUnits($user); + $total = count($resources); + [$offset, $limit] = $this->getOffsetAndLimit(); + + return $this->getPaginatedContentResponse(array_slice($resources, $offset, $limit), $total); + } +} diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index e7168cd998900a09b7c811cd7a3441e59f08c45f..44b12d9639bf0445f815a89c0990b7333995e724 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -59,6 +59,7 @@ class SchemaMap \Courseware\StructuralElement::class => Schemas\Courseware\StructuralElement::class, \Courseware\StructuralElementComment::class => Schemas\Courseware\StructuralElementComment::class, \Courseware\StructuralElementFeedback::class => Schemas\Courseware\StructuralElementFeedback::class, + \Courseware\Unit::class => Schemas\Courseware\Unit::class, \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class, \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class, \Courseware\Task::class => Schemas\Courseware\Task::class, diff --git a/lib/classes/JsonApi/Schemas/Courseware/Instance.php b/lib/classes/JsonApi/Schemas/Courseware/Instance.php index 63a4d950b831a2d6818a855b221aa29add7c889e..ff109661a41c393e1075ead13d261de059994f22 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/Instance.php +++ b/lib/classes/JsonApi/Schemas/Courseware/Instance.php @@ -19,8 +19,9 @@ class Instance extends SchemaProvider public function getId($resource): ?string { $root = $resource->getRoot(); + $unit = \Courseware\Unit::findOneBySQL('structural_element_id = ?', [$root->id]); - return join('_', [$root->range_type, $root->range_id]); + return join('_', [$root->range_type, $root->range_id, $unit->id]); } /** @@ -34,11 +35,12 @@ class Instance extends SchemaProvider 'block-types' => array_map([$this, 'mapBlockType'], $resource->getBlockTypes()), 'container-types' => array_map([$this, 'mapContainerType'], $resource->getContainerTypes()), 'favorite-block-types' => $resource->getFavoriteBlockTypes($user), - 'sequential-progression' => (bool) $resource->getSequentialProgression(), + 'sequential-progression' => $resource->getSequentialProgression(), 'editing-permission-level' => $resource->getEditingPermissionLevel(), 'certificate-settings' => $resource->getCertificateSettings(), 'reminder-settings' => $resource->getReminderSettings(), - 'reset-progress-settings' => $resource->getResetProgressSettings() + 'reset-progress-settings' => $resource->getResetProgressSettings(), + 'root-id' => $resource->getRoot()->id ]; } diff --git a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php index b50b17979ef4a8b4534f5a83ba47bbe64db741d2..4335e89241707c55953c8213cacb71eab17674b6 100644 --- a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php +++ b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php @@ -23,6 +23,7 @@ class StructuralElement extends SchemaProvider const REL_PARENT = 'parent'; const REL_USER = 'user'; const REL_TASK = 'task'; + const REL_UNIT = 'unit'; /** * {@inheritdoc} @@ -51,6 +52,7 @@ class StructuralElement extends SchemaProvider 'write-approval' => $resource['write_approval']->getIterator(), 'copy-approval' => $resource['copy_approval']->getIterator(), 'can-edit' => $resource->canEdit($user), + 'can-visit' => $resource->canVisit($user), 'is-link' => (int) $resource['is_link'], 'target-id' => (int) $resource['target_id'], 'external-relations' => $resource['external_relations']->getIterator(), @@ -131,6 +133,12 @@ class StructuralElement extends SchemaProvider $this->shouldInclude($context, self::REL_TASK) ); + $relationships = $this->addUnitRelationship( + $relationships, + $resource, + $this->shouldInclude($context, self::REL_UNIT) + ); + return $relationships; } @@ -355,6 +363,22 @@ class StructuralElement extends SchemaProvider return $relationships; } + private function addUnitRelationship(array $relationships, $resource, $includeData): array + { + $relation = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_UNIT), + ], + ]; + + $related = $resource->findUnit(); + $relation[self::RELATIONSHIP_DATA] = $related; + + $relationships[self::REL_UNIT] = $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 new file mode 100644 index 0000000000000000000000000000000000000000..a1c554ec62c05e3d4ce8c8bcb09a49bcc600a762 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/Unit.php @@ -0,0 +1,79 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class Unit extends SchemaProvider +{ + const TYPE = 'courseware-units'; + + const REL_CREATOR= 'creator'; + const REL_RANGE = 'range'; + const REL_STRUCTURAL_ELEMENT = 'structural-element'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'content-type' => (string) $resource['content_type'], + 'public' => (int) $resource['public'], + 'release-date' => $resource['release_date'] ? date('c', $resource['release_date']) : null, + 'withdraw-date' => $resource['withdraw_date'] ? date('c', $resource['withdraw_date']) : null, + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships[self::REL_CREATOR] = $resource['creator_id'] + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->creator), + ], + self::RELATIONSHIP_DATA => $resource->creator, + ] + : [self::RELATIONSHIP_DATA => null]; + + $relationships[self::REL_STRUCTURAL_ELEMENT] = $resource['structural_element_id'] + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->structural_element), + ], + self::RELATIONSHIP_DATA => $resource->structural_element, + ] + : [self::RELATIONSHIP_DATA => null]; + + $rangeType = $resource->range_type; + $range = $resource->$rangeType; + + $relationships[self::REL_RANGE] = $range + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($range), + ], + self::RELATIONSHIP_DATA => $range, + ] + : [self::RELATIONSHIP_DATA => null]; + + return $relationships; + } +} \ No newline at end of file diff --git a/lib/models/Courseware/Instance.php b/lib/models/Courseware/Instance.php index 0b388fe93ca2db2c340ded696361f95cf2d2fbb3..63bd34bd8aa91fc9830193c69a48abbf16689d75 100644 --- a/lib/models/Courseware/Instance.php +++ b/lib/models/Courseware/Instance.php @@ -165,9 +165,10 @@ class Instance public function getSequentialProgression(): bool { $range = $this->getRange(); - $config = $range->getConfiguration()->getValue('COURSEWARE_SEQUENTIAL_PROGRESSION'); + $root = $this->getRoot(); + $sequentialProgression = $range->getConfiguration()->COURSEWARE_SEQUENTIAL_PROGRESSION[$root->id]; - return (bool) $config; + return (bool) $sequentialProgression; } /** @@ -178,7 +179,10 @@ class Instance public function setSequentialProgression(bool $isSequentialProgression): void { $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_SEQUENTIAL_PROGRESSION', $isSequentialProgression); + $root = $this->getRoot(); + $progressions = $range->getConfiguration()->getValue('COURSEWARE_SEQUENTIAL_PROGRESSION'); + $progressions[$root->id] = $isSequentialProgression ? 1 : 0; + $range->getConfiguration()->store('COURSEWARE_SEQUENTIAL_PROGRESSION', $progressions); } const EDITING_PERMISSION_DOZENT = 'dozent'; @@ -192,11 +196,15 @@ class Instance public function getEditingPermissionLevel(): string { $range = $this->getRange(); + $root = $this->getRoot(); /** @var string $editingPermissionLevel */ - $editingPermissionLevel = $range->getConfiguration()->getValue('COURSEWARE_EDITING_PERMISSION'); - $this->validateEditingPermissionLevel($editingPermissionLevel); + $editingPermissionLevel = $range->getConfiguration()->COURSEWARE_EDITING_PERMISSION[$root->id]; + if ($editingPermissionLevel) { + $this->validateEditingPermissionLevel($editingPermissionLevel); + return $editingPermissionLevel; + } - return $editingPermissionLevel; + return self::EDITING_PERMISSION_TUTOR; // tutor is default } /** @@ -209,7 +217,10 @@ class Instance { $this->validateEditingPermissionLevel($editingPermissionLevel); $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_EDITING_PERMISSION', $editingPermissionLevel); + $root = $this->getRoot(); + $permissions = $range->getConfiguration()->getValue('COURSEWARE_EDITING_PERMISSION'); + $permissions[$root->id] = $editingPermissionLevel; + $range->getConfiguration()->store('COURSEWARE_EDITING_PERMISSION', $permissions); } /** @@ -240,9 +251,10 @@ class Instance public function getCertificateSettings(): array { $range = $this->getRange(); + $root = $this->getRoot(); /** @var array $certificateSettings */ $certificateSettings = json_decode( - $range->getConfiguration()->getValue('COURSEWARE_CERTIFICATE_SETTINGS'), + $range->getConfiguration()->COURSEWARE_CERTIFICATE_SETTINGS[$root->id], true )?: []; $this->validateCertificateSettings($certificateSettings); @@ -259,8 +271,10 @@ class Instance { $this->validateCertificateSettings($certificateSettings); $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_CERTIFICATE_SETTINGS', - count($certificateSettings) > 0 ? json_encode($certificateSettings) : null); + $root = $this->getRoot(); + $settings = $range->getConfiguration()->getValue('COURSEWARE_CERTIFICATE_SETTINGS'); + $settings[$root->id] = count($certificateSettings) > 0 ? json_encode($certificateSettings) : null; + $range->getConfiguration()->store('COURSEWARE_CERTIFICATE_SETTINGS', $settings); } /** @@ -290,9 +304,10 @@ class Instance public function getReminderSettings(): array { $range = $this->getRange(); + $root = $this->getRoot(); /** @var int $reminderInterval */ $reminderSettings = json_decode( - $range->getConfiguration()->getValue('COURSEWARE_REMINDER_SETTINGS'), + $range->getConfiguration()->COURSEWARE_REMINDER_SETTINGS[$root->id], true )?: []; $this->validateReminderSettings($reminderSettings); @@ -309,8 +324,10 @@ class Instance { $this->validateReminderSettings($reminderSettings); $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_REMINDER_SETTINGS', - count($reminderSettings) > 0 ? json_encode($reminderSettings) : null); + $root = $this->getRoot(); + $settings = $range->getConfiguration()->getValue('COURSEWARE_REMINDER_SETTINGS'); + $settings[$root->id] = count($reminderSettings) > 0 ? json_encode($reminderSettings) : null; + $range->getConfiguration()->store('COURSEWARE_REMINDER_SETTINGS', $settings); } /** @@ -342,9 +359,10 @@ class Instance public function getResetProgressSettings(): array { $range = $this->getRange(); + $root = $this->getRoot(); /** @var int $reminderInterval */ $resetProgressSettings = json_decode( - $range->getConfiguration()->getValue('COURSEWARE_RESET_PROGRESS_SETTINGS'), + $range->getConfiguration()->COURSEWARE_RESET_PROGRESS_SETTINGS[$root->id], true )?: []; $this->validateResetProgressSettings($resetProgressSettings); @@ -361,8 +379,10 @@ class Instance { $this->validateResetProgressSettings($resetProgressSettings); $range = $this->getRange(); - $range->getConfiguration()->store('COURSEWARE_RESET_PROGRESS_SETTINGS', - count($resetProgressSettings) > 0 ? json_encode($resetProgressSettings) : null); + $root = $this->getRoot(); + $settings = $range->getConfiguration()->getValue('COURSEWARE_RESET_PROGRESS_SETTINGS'); + $settings[$root->id] = count($resetProgressSettings) > 0 ? json_encode($resetProgressSettings) : null; + $range->getConfiguration()->store('COURSEWARE_RESET_PROGRESS_SETTINGS', $settings); } /** diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index 7206199e9ff6dc88fed170f12acd166607b256d8..9aa4f90e6259233a08b3327e783e618a1b7154d2 100644 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -375,7 +375,7 @@ class StructuralElement extends \SimpleORMap { return $GLOBALS['perm']->have_perm('root', $user->id) || $GLOBALS['perm']->have_studip_perm( - \CourseConfig::get($this->range_id)->COURSEWARE_EDITING_PERMISSION, + \CourseConfig::get($this->range_id)->COURSEWARE_EDITING_PERMISSION[$this->getCoursewareCourse($this->range_id)->id], $this->range_id, $user->id ); @@ -653,6 +653,17 @@ class StructuralElement extends \SimpleORMap return $ancestors; } + public function findUnit() + { + if ($this->isRootNode()) { + $root = $this; + } else { + $root = $this->findAncestors()[0]; + } + + return Unit::findOneBySQL('structural_element_id = ?', [$root->id]); + } + /** * Returns the list of all descendants of this instance in depth-first search order. * @@ -760,6 +771,42 @@ SQL; return $this->image ? $this->image->getDownloadURL() : null; } + /** + * Copies this instance into another course oder users contents. + * + * @param User $user this user will be the owner of the copy + * @param Range $parent the target where to copy this instance + * + * @return StructuralElement the copy of this instance + */ + public function copyToRange(User $user, string $rangeId, string $rangeType, string $purpose = ''): StructuralElement + { + $element = self::build([ + 'parent_id' => null, + 'range_id' => $rangeId, + 'range_type' => $rangeType, + 'owner_id' => $user->id, + 'editor_id' => $user->id, + 'edit_blocker_id' => null, + 'title' => $this->title, + 'purpose' => $purpose ?: $this->purpose, + 'position' => 0, + 'payload' => $this->payload, + ]); + + $element->store(); + + $file_ref_id = $this->copyImage($user, $element); + $element->image_id = $file_ref_id; + $element->store(); + + $this->copyContainers($user, $element); + + $this->copyChildren($user, $element, $purpose); + + return $element; + } + /** * Copies this instance as a child into another structural element. * diff --git a/lib/models/Courseware/Unit.php b/lib/models/Courseware/Unit.php new file mode 100644 index 0000000000000000000000000000000000000000..acf2a93ab7f518316e26641063cd6725a73c9eb1 --- /dev/null +++ b/lib/models/Courseware/Unit.php @@ -0,0 +1,112 @@ +<?php + +namespace Courseware; + +use phootwork\collection\ArrayList; +use phpDocumentor\Reflection\Types\Array_; +use User; + +/** + * Courseware's units. + * + * @author Ron Lucke <lucke@elan-ev.de> + * @license GPL2 or any later version + * + * @since Stud.IP 5.3 + * + * @property int $id database column + * @property string $range_id database column + * @property string $range_type database column + * @property int $structural_element_id database column + * @property string $content_type database column + * @property int $public database column + * @property string $creator_id database column + * @property int $release_date database column + * @property int $withdraw_date database column + * @property int $mkdate database column + * @property int $chdate database column + * @property \User $creator belongs_to User + * @property \Courseware\StructuralElement $structural_element belongs_to Courseware\StructuralElement + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + */ + +class Unit extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_units'; + + $config['has_one']['structural_element'] = [ + 'class_name' => StructuralElement::class, + 'foreign_key' => 'structural_element_id', + 'on_delete' => 'delete', + ]; + $config['belongs_to']['course'] = [ + 'class_name' => \Course::class, + 'foreign_key' => 'range_id', + 'assoc_foreign_key' => 'seminar_id', + ]; + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'range_id', + 'assoc_foreign_key' => 'user_id', + ]; + $config['belongs_to']['creator'] = [ + 'class_name' => User::class, + 'foreign_key' => 'creator_id', + ]; + + parent::configure($config); + } + + public static function findCoursesUnits(\Course $course): array + { + return self::findBySQL('range_id = ? AND range_type = ?', [$course->id, 'course']); + } + + public static function findUsersUnits(\User $user): array + { + return self::findBySQL('range_id = ? AND range_type = ?', [$user->id, 'user']); + } + + public function canRead(\User $user): bool + { + return $this->structural_element->canRead($user); + } + + public function canEdit(\User $user): bool + { + return $this->structural_element->canEdit($user);; + } + + public function copy(\User $user, string $rangeId, string $rangeType, array $modified = null): Unit + { + $sourceUnitElement = $this->structural_element; + + $newElement = $sourceUnitElement->copyToRange($user, $rangeId, $rangeType); + + if ($modified !== null) { + $newElement->title = $modified['title'] ?? $newElement->title; + $newElement->payload['color'] = $modified['color'] ?? 'studip-blue'; + $newElement->payload['description'] = $modified['description'] ?? $newElement->payload['description']; + $newElement->store(); + } + + $newUnit = \Courseware\Unit::build([ + 'range_id' => $rangeId, + 'range_type' => $rangeType, + 'structural_element_id' => $newElement->id, + 'content_type' => 'courseware', + 'creator_id' => $user->id, + 'public' => '', + 'release_date' => '', + 'withdraw_date' => '', + ]); + + $newUnit->store(); + + return $newUnit; + } +} diff --git a/lib/modules/CoursewareModule.class.php b/lib/modules/CoursewareModule.class.php index e07d5d20aa8e006463e5ba93dbc7306734c27325..76280e602e0f594e53331007aecb850b6a7b5b52 100644 --- a/lib/modules/CoursewareModule.class.php +++ b/lib/modules/CoursewareModule.class.php @@ -41,31 +41,21 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule, ); $navigation->setImage(Icon::create('courseware', Icon::ROLE_INFO_ALT)); $navigation->addSubNavigation( - 'content', - new Navigation(_('Inhalt'), 'dispatch.php/course/courseware/?cid='.$courseId) + 'shelf', + new Navigation(_('Lernmaterialien'), 'dispatch.php/course/courseware/?cid=' . $courseId) ); $navigation->addSubNavigation( - 'dashboard', - new Navigation(_('Übersicht'), 'dispatch.php/course/courseware/dashboard?cid='.$courseId) + 'unit', + new Navigation(_('Inhalt'), 'dispatch.php/course/courseware/courseware?cid=' . $courseId) + ); + $navigation->addSubNavigation( + 'activities', + new Navigation(_('Aktivitäten'), 'dispatch.php/course/courseware/activities?cid=' . $courseId) + ); + $navigation->addSubNavigation( + 'tasks', + new Navigation(_('Aufgaben'), 'dispatch.php/course/courseware/tasks?cid=' . $courseId) ); - - if ($GLOBALS['perm']->have_studip_perm('dozent', $courseId)) { - $navigation->addSubNavigation( - 'manager', - new Navigation(_('Verwaltung'), 'dispatch.php/course/courseware/manager?cid='.$courseId) - ); - } else { - $element = StructuralElement::getCoursewareCourse($courseId); - if ($element !== null) { - $instance = new Instance($element); - if ($GLOBALS['perm']->have_studip_perm($instance->getEditingPermissionLevel(), $courseId)) { - $navigation->addSubNavigation( - 'manager', - new Navigation(_('Verwaltung'), 'dispatch.php/course/courseware/manager?cid='.$courseId) - ); - } - } - } return ['courseware' => $navigation]; } @@ -129,10 +119,10 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule, { return [ 'summary' => _('Lerninhalte erstellen, verteilen und erleben'), - 'description' => _('Mit Courseware können Sie interaktive multimediale Lerninhalte erstellen und nutzen. ' + 'description' => _('Mit Courseware können Sie interaktive, multimediale Lerninhalte erstellen und nutzen. ' . 'Die Lerninhalte lassen sich hierarchisch unterteilen und können aus Texten, ' . 'Videosequenzen, Aufgaben, Kommunikationselementen und einer Vielzahl weiterer ' - . 'Elementen bestehen. Fertige Lerninhalte können exportiert und in andere Kurse oder ' + . 'Elemente bestehen. Fertige Lerninhalte können exportiert und in andere Kurse oder ' . 'andere Installationen importiert werden. Courseware ist nicht nur für digitale ' . 'Formate geeignet, sondern kann auch genutzt werden, um klassische ' . 'Präsenzveranstaltungen mit Online-Anteilen zu ergänzen. Formate wie integriertes ' diff --git a/lib/navigation/ContentsNavigation.php b/lib/navigation/ContentsNavigation.php index 965f598b0b7edcb8892afa6de28f48833ff536ac..119de6a73d0631af9943d8b17efa88d09d7680a6 100644 --- a/lib/navigation/ContentsNavigation.php +++ b/lib/navigation/ContentsNavigation.php @@ -52,16 +52,12 @@ class ContentsNavigation extends Navigation $courseware->setImage(Icon::create('courseware')); $courseware->addSubNavigation( - 'overview', + 'shelf', 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') + new Navigation(_('Inhalt'), 'dispatch.php/contents/courseware/courseware') ); $courseware->addSubNavigation( 'releases', diff --git a/package-lock.json b/package-lock.json index bcca55a61970f103d4cda7bbec5cc2a5e0dd6fbe..1e7c02745241fa7f9d1ccbc0c1ec8897940a48c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5087,9 +5087,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001341", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz", - "integrity": "sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA==", + "version": "1.0.30001429", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001429.tgz", + "integrity": "sha512-511ThLu1hF+5RRRt0zYCf2U2yRr9GPF6m5y90SBCWsvSoYoW7yAGlv/elyPaNfvGCkp6kj/KFZWU0BMA69Prsg==", "dev": true, "funding": [ { @@ -17974,9 +17974,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001341", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz", - "integrity": "sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA==", + "version": "1.0.30001429", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001429.tgz", + "integrity": "sha512-511ThLu1hF+5RRRt0zYCf2U2yRr9GPF6m5y90SBCWsvSoYoW7yAGlv/elyPaNfvGCkp6kj/KFZWU0BMA69Prsg==", "dev": true }, "chalk": { diff --git a/resources/assets/javascripts/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js index 7124ac9a8cc698d9e7505c02035a158c9195ad70..503ae89f873a53ad0a236a4e03f5aaf44fa221e7 100644 --- a/resources/assets/javascripts/bootstrap/courseware.js +++ b/resources/assets/javascripts/bootstrap/courseware.js @@ -1,4 +1,15 @@ STUDIP.domReady(() => { + if (document.getElementById('courseware-shelf-app')) { + STUDIP.Vue.load().then(({ createApp }) => { + import( + /* webpackChunkName: "courseware-shelf-app" */ + '@/vue/courseware-shelf-app.js' + ).then(({ default: mountApp }) => { + return mountApp(STUDIP, createApp, '#courseware-shelf-app'); + }); + }); + } + if (document.getElementById('courseware-index-app')) { STUDIP.Vue.load().then(({ createApp }) => { import( @@ -10,35 +21,35 @@ STUDIP.domReady(() => { }); } - if (document.getElementById('courseware-dashboard-app')) { + if (document.getElementById('courseware-activities-app')) { STUDIP.Vue.load().then(({ createApp }) => { import( - /* webpackChunkName: "courseware-dashboard-app" */ - '@/vue/courseware-dashboard-app.js' + /* webpackChunkName: "courseware-activities-app" */ + '@/vue/courseware-activities-app.js' ).then(({ default: mountApp }) => { - return mountApp(STUDIP, createApp, '#courseware-dashboard-app'); + return mountApp(STUDIP, createApp, '#courseware-activities-app'); }); }); } - if (document.getElementById('courseware-manager-app')) { + if (document.getElementById('courseware-tasks-app')) { STUDIP.Vue.load().then(({ createApp }) => { import( - /* webpackChunkName: "courseware-manager-app" */ - '@/vue/courseware-manager-app.js' + /* webpackChunkName: "courseware-tasks-app" */ + '@/vue/courseware-tasks-app.js' ).then(({ default: mountApp }) => { - return mountApp(STUDIP, createApp, '#courseware-manager-app'); + return mountApp(STUDIP, createApp, '#courseware-tasks-app'); }); }); } - if (document.getElementById('courseware-content-overview-app')) { + if (document.getElementById('courseware-manager-app')) { STUDIP.Vue.load().then(({ createApp }) => { import( - /* webpackChunkName: "courseware-content-overview-app" */ - '@/vue/courseware-content-overview-app.js' + /* webpackChunkName: "courseware-manager-app" */ + '@/vue/courseware-manager-app.js' ).then(({ default: mountApp }) => { - return mountApp(STUDIP, createApp, '#courseware-content-overview-app'); + return mountApp(STUDIP, createApp, '#courseware-manager-app'); }); }); } diff --git a/resources/assets/stylesheets/scss/buttons.scss b/resources/assets/stylesheets/scss/buttons.scss index 05f2a26e504e2579bb272d6f3e2add249f340959..ef819817c0e207401fc898f5deb53cc6446368a9 100644 --- a/resources/assets/stylesheets/scss/buttons.scss +++ b/resources/assets/stylesheets/scss/buttons.scss @@ -106,6 +106,16 @@ button.button { .button.search { @include button-with-icon(search, clickable, info_alt); } +.button.arr_left { + @include button-with-icon(arr_1left, clickable, info_alt); +} +.button.arr_right { + @include button-with-icon(arr_1right, clickable, info_alt); + &::before { + float: right; + margin: 1px -8px 0 5px; + } +} /* Grouped Buttons */ .button-group { diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index b1fc6be83e7b50ac6daed0c28a4889639e695480..804165129e439fb02d5ec409cb53acf059ceecf5 100644 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -7,7 +7,8 @@ $companion-types: ( alert: alert, sad: sad, happy: happy, - pointing: pointing-right + pointing: pointing-right, + curious: curious ); $element-icons: ( @@ -246,9 +247,9 @@ c o n t e n t s e n d r i b b o n * * * * * */ $consum_ribbon_width: calc(100% - 58px); -#course-courseware-index, +#course-courseware-courseware, #contents-courseware-courseware, -#contents-courseware-shared_content_courseware { +#contents-courseware-shared_content_courseware { &.consume { overflow: hidden; } @@ -621,8 +622,8 @@ ribbon end scrollbar-color: $base-color #f5f5f5; } - .cw-wellcome-screen { - .cw-wellcome-screen-keyvisual { + .cw-welcome-screen { + .cw-welcome-screen-keyvisual { margin: 14px 0 42px 0; width: 100%; height: 400px; @@ -635,7 +636,7 @@ ribbon end text-align: center; font-size: 2.25em; } - .cw-wellcome-screen-actions { + .cw-welcome-screen-actions { display: flex; flex-wrap: wrap; justify-content: center; @@ -799,6 +800,8 @@ ribbon end } } + + /* * * * * * * * * * * * structual element end * * * * * * * * * * * */ @@ -2084,6 +2087,17 @@ v i e w w i d g e t @include background-icon(oer-campus, clickable); } } +.cw-import-widget { + .cw-import-widget-archive{ + @include background-icon(file-archive, clickable); + } + .cw-import-widget-copy{ + @include background-icon(files, clickable); + } + .cw-import-widget-import{ + @include background-icon(import, clickable); + } +} /* * * * * * * * * * * * * * v i e w w i d g e t e n d @@ -2218,118 +2232,6 @@ textarea.studip-wysiwyg { w y s i w y g e n d * * * * * * * * * * */ -/* * * * * * -d i a l o g -* * * * * */ - -.studip-dialog-backdrop { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-color: fade-out($base-color, 0.5); - display: flex; - justify-content: center; - align-items: center; - z-index: 3001; -} -.studip-dialog-body { - position: absolute; - background: $white; - box-shadow: 0 0 8px fade-out($black, 0.5); - overflow-x: auto; - display: flex; - flex-direction: column; - padding: 3px; - margin: 3px; - max-height: 98vh; - - .studip-dialog-header, - .studip-dialog-footer { - padding: 7px; - display: flex; - } - .studip-dialog-header { - background: $base-color none repeat scroll 0 0; - border-bottom: 1px solid $dark-gray-color-10; - color: $white; - justify-content: space-between; - font-size: 1.3em; - padding: 0.5em 1em; - cursor: grab; - - &.drag-active { - cursor: grabbing; - } - } - .studip-dialog-close-button { - @include background-icon(decline, info-alt); - background-repeat: no-repeat; - background-position-y: center; - background-color: transparent; - border: none; - - width: 22px; - height: 22px; - margin-right: -10px; - margin-left: 2em; - cursor: pointer; - } - .studip-dialog-content { - color: $black; - position: relative; - padding: 15px; - overflow-y: auto; - min-width: 100%; - // resize: both; - box-sizing: border-box; - } - .studip-dialog-footer { - border-top: 1px solid $dark-gray-color-10; - justify-content: center; - } - - &.studip-dialog-warning, - &.studip-dialog-alert { - .studip-dialog-content { - padding: 15px 15px 15px 62px; - background-position: 12px center; - background-repeat: no-repeat; - box-sizing: border-box; - display: flex; - align-items: center; - } - } - - &.studip-dialog-alert { - .studip-dialog-header { - background: $active-color none repeat scroll 0 0; - } - .studip-dialog-content { - @include background-icon(question-circle-full, attention, 32); - } - } - &.studip-dialog-warning { - .studip-dialog-header { - color: $black; - background: $activity-color none repeat scroll 0 0; - } - .studip-dialog-close-button { - @include background-icon(decline, clickable); - border: none; - background-color: transparent; - } - .studip-dialog-content { - @include background-icon(question-circle-full, status-yellow, 32); - } - } - -} -/* * * * * * * * * -d i a l o g e n d -* * * * * * * * */ - /* * * * * * * * * d a s h b o a r d * * * * * * * * */ @@ -2362,56 +2264,7 @@ d a s h b o a r d justify-content: center; } - .cw-dashboard-progress { - - .cw-dashboard-progress-breadcrumb { - padding: 10px; - span { - color: $base-color; - cursor: pointer; - - &:hover { - color: $active-color; - } - } - } - - .cw-dashboard-progress-chapter { - text-align: center; - margin-bottom: -3.5em; - - h1 { - border: none; - margin: 0; - padding: 0; - } - - .cw-progress-circle { - font-size: 18px; - margin: 1em auto; - - &.cw-dashboard-progress-current { - font-size: 12px; - top: -4.5em; - left: -2.5em; - } - } - } - - .cw-dashboard-progress-subchapter-list { - border-top: solid thin $content-color-40; - height: 349px; - overflow-y: scroll; - overflow-x: hidden; - padding: 0 1em 0 1em; - scrollbar-width: thin; - scrollbar-color: $base-color $dark-gray-color-5; - .cw-dashboard-empty-info { - margin-top: 10px; - } - } - } &.cw-dashboard-task-view { display: unset; max-width: unset; @@ -2422,12 +2275,6 @@ d a s h b o a r d max-height: unset; } } - &.cw-dashboard-activity-view { - .cw-dashboard-activities { - max-height: 760px; - } - - } } #course-courseware-dashboard { @@ -2450,60 +2297,16 @@ d a s h b o a r d } } -.cw-dashboard-progress-item { - display: block; - border-bottom: solid thin $content-color-40; - padding: 10px 0; - - &:hover{ - background-color: hsla(217,6%,45%,.2); - } - - &:last-child { - border: none; - } - - .cw-dashboard-progress-item-value, - .cw-dashboard-progress-item-description { - display: inline-block; - vertical-align: top; - } +.cw-activities-wrapper { + max-width: 1095px; - .cw-dashboard-progress-item-value { - width: 70px; - color: $base-color; - font-size: xx-large; - - .cw-progress-circle { - font-size: 12px; - margin: 4px; - } - } - .cw-dashboard-progress-item-description { - color: $base-color; - padding-left: 14px; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - padding: 0.5em 0 0 1em; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } -} -.cw-dashboard-activities-wrapper { .cw-companion-box { margin: 10px; } - .cw-dashboard-activities { - max-height: 525px; + .cw-activities { list-style: none; padding: 0; - scrollbar-width: thin; - scrollbar-color:$base-color #f5f5f5; - overflow-y: auto; - overflow-x: hidden; .cw-activity-item { border-bottom: solid thin $content-color-40; @@ -2519,12 +2322,8 @@ d a s h b o a r d padding-right: 0.5em; vertical-align: text-bottom; } - &.cw-activity-item-text { - padding-left: 23px; - } } } - } } @@ -2537,10 +2336,6 @@ d a s h b o a r d .cw-dashboard-tasks-wrapper, .cw-dashboard-students-wrapper { - overflow-x: auto; - scrollbar-width: thin; - scrollbar-color:$base-color #f5f5f5; - max-height: 280px; table.default { margin: 0; @@ -2576,6 +2371,99 @@ d a s h b o a r d d a s h b o a r d e n d * * * * * * * * * * * */ +/* * * * * * * * * * * * + p r o g r e s s +* * * * * * * * * * * */ +.cw-unit-progress { + .cw-unit-progress-breadcrumb { + padding: 10px; + span { + color: $base-color; + cursor: pointer; + + &:hover { + color: $active-color; + } + } + } + + .cw-unit-progress-chapter { + text-align: center; + margin-bottom: -3.5em; + + h1 { + border: none; + margin: 0; + padding: 0; + } + + .cw-progress-circle { + font-size: 18px; + margin: 1em auto; + + &.cw-unit-progress-current { + font-size: 12px; + top: -4.5em; + left: -2.5em; + } + } + } + + .cw-unit-progress-subchapter-list { + border-top: solid thin $content-color-40; + padding: 0 1em 0 1em; + + .cw-dashboard-empty-info { + margin-top: 10px; + } + } +} + +.cw-unit-progress-item { + display: block; + border-bottom: solid thin $content-color-40; + padding: 10px 0; + + &:hover{ + background-color: hsla(217,6%,45%,.2); + } + + &:last-child { + border: none; + } + + .cw-unit-progress-item-value, + .cw-unit-progress-item-description { + display: inline-block; + vertical-align: top; + } + + .cw-unit-progress-item-value { + width: 70px; + color: $base-color; + font-size: xx-large; + + .cw-progress-circle { + font-size: 12px; + margin: 4px; + } + } + .cw-unit-progress-item-description { + color: $base-color; + padding-left: 14px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + padding: 0.5em 0 0 1em; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } +} +/* * * * * * * * * * * * + p r o g r e s s e n d +* * * * * * * * * * * */ + /* * * * * * o b l o n g * * * * * */ @@ -5178,102 +5066,156 @@ cw tiles padding-left: 0; row-gap: 5px; column-gap: 5px; +} +.cw-tiles .tile, +.cw-tile { + height: 420px; + width: 270px; + margin: 0; + background-color: $base-color; + &:last-child { + margin-right: 0; + } - .tile { - height: 420px; - width: 270px; - margin: 0; - background-color: $base-color; - cursor: pointer; - &:last-child { - margin-right: 0; + @each $name, $color in $tile-colors { + &.#{"" + $name} { + background-color: $color; } + }; +} - @each $name, $color in $tile-colors { - &.#{"" + $name} { - background-color: $color; - } - }; +.preview-image { + height: 180px; + width: 100%; + background-size: auto 180px; + background-repeat: no-repeat; + background-color: $content-color-20; + background-position: center; + &.default-image { + @include background-icon(courseware, clickable, 128); } - .preview-image { - height: 180px; - width: 100%; - background-size: auto 180px; - background-repeat: no-repeat; - background-color: $content-color-20; - background-position: center; - &.default-image { - @include background-icon(courseware, clickable, 128); - } + .overlay-text { + padding: 6px 7px; + margin: 4px; + background-color: rgba(255,255,255,0.8); + width: fit-content; + max-width: 100%; + height: 1.25em; + overflow: hidden; + text-overflow: ellipsis; + float: right; + text-align: right; + } - .overlay-text { - padding: 0.25em; - margin: 0.25em; - background-color: rgba(255,255,255,0.8); - width: fit-content; - max-width: 100%; - height: 1.25em; - overflow: hidden; - text-overflow: ellipsis; - float: right; - text-align: right; + .overlay-action-menu { + padding: 0; + margin: 0.25em; + background-color: rgba(255,255,255,0.8); + width: fit-content; + max-width: 100%; + overflow: hidden; + float: right; + text-align: right; + .action-menu { + margin: 5px; } } +} - .description { - height: 220px; - padding: 14px; - color: $white; - position: relative; +.description { + height: 220px; + padding: 14px; + color: $white; + position: relative; + display: block; - header { - font-size: 20px; - line-height: 22px; - color: $white; - border: none; - margin-bottom: 0.75em; - width: 240px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - background-repeat: no-repeat; - background-position: 0 0; + header { + font-size: 20px; + line-height: 22px; + color: $white; + border: none; + width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background-repeat: no-repeat; + background-position: 0 0; - @each $type, $icon in $element-icons { - &.description-icon-#{$type} { - width: 212px; - padding-left: 28px; - @include background-icon(#{$icon}, info_alt, 22); - } + @each $type, $icon in $element-icons { + &.description-icon-#{$type} { + width: 212px; + padding-left: 28px; + @include background-icon(#{$icon}, info_alt, 22); } } + } - .description-text-wrapper { - overflow: hidden; - height: 10em; - display: -webkit-box; - margin-bottom: 1em; - -webkit-line-clamp: 7; - -webkit-box-orient: vertical; - p { - text-align: left; + .progress-wrapper { + width: 100%; + padding: 1em 0; + border: none; + background: none; + + progress { + display: block; + width: 100%; + height: 3px; + margin: 0; + border: none; + background: rgba(0,0,0,0.3); + &:-webkit-progress-bar { + background: rgba(0,0,0,0.3); + } + &::-webkit-progress-value { + background: white; + } + &::-moz-progress-bar { + background: white; } } + } - footer { - width: 242px; - text-align: right; - color: $white; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - img { - vertical-align: text-bottom; - } + + .description-text-wrapper { + overflow: hidden; + height: 10em; + margin-top: 0.5em; + display: -webkit-box; + margin-bottom: 1em; + -webkit-line-clamp: 7; + -webkit-box-orient: vertical; + p { + text-align: left; } } + + footer { + width: 242px; + text-align: right; + color: $white; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + img { + vertical-align: text-bottom; + } + } +} + +a[href].description { + transition: unset; +} + +a.description, +a.description:link, +a.description:visited, +a.description:hover { + height: 210px; + color: $white; + text-decoration: unset; } /* @@ -5357,7 +5299,7 @@ cw tiles end .cw-file-input { width: stretch; border: solid thin $base-color; - font-size: 13px; + font-size: 14px; cursor: pointer; &::file-selector-button { @@ -5374,6 +5316,20 @@ cw tiles end } } } + .cw-file-input-change { + border: solid thin $base-color; + + button.button { + padding: 0.5em 1.5em; + margin: 0 0 0 -1px; + line-height: 100%; + border: none; + border-right: solid thin $base-color; + } + span { + padding: 0.5em 1.5em 0.5em 0.5em; + } + } /* * * * * * * * * * * * * i n p u t f i l e e n d * * * * * * * * * * * * * */ @@ -5412,3 +5368,31 @@ a s s i s t i v e /* * * * * * * * * * * * * * * e n d a s s i s t i v e * * * * * * * * * * * * * * */ + +/* * * * * * * * * * * * * * * +w i z a r d e l e m e n t s +* * * * * * * * * * * * * * */ +.cw-element-selector-list { + list-style: none; + padding: 0; + + .cw-element-selector-item { + display: block; + width: 100%; + border: solid thin $content-color-40; + padding: 0.5em; + margin-bottom: 5px; + background-color: $white; + color: $base-color; + text-align: left; + cursor: pointer; + + &:hover { + color: $white; + background-color: $base-color; + } + } +} +/* * * * * * * * * * * * * * * * * * +w i z a r d e l e m e n t s e n d +* * * * * * * * * * * * * * * * * */ diff --git a/resources/assets/stylesheets/scss/dialog.scss b/resources/assets/stylesheets/scss/dialog.scss index d273ccb9d91e3efe4fee2adbd360650b7927fa3e..eaa4e3b20cff36ce46fbf29484d954e6662f66e4 100644 --- a/resources/assets/stylesheets/scss/dialog.scss +++ b/resources/assets/stylesheets/scss/dialog.scss @@ -307,3 +307,114 @@ h2.dialog-subtitle { margin-top: 0.25em; margin-bottom: 0.25em; } + +/* * * * * * * * * +v u e d i a l o g +* * * * * * * * */ + +.studip-dialog-backdrop { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: fade-out($base-color, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 3001; +} +.studip-dialog-body { + position: absolute; + background: $white; + box-shadow: 0 0 8px fade-out($black, 0.5); + overflow-x: auto; + display: flex; + flex-direction: column; + padding: 3px; + margin: 3px; + max-height: 98vh; + + .studip-dialog-header, + .studip-dialog-footer { + padding: 7px; + display: flex; + } + .studip-dialog-header { + background: $base-color none repeat scroll 0 0; + border-bottom: 1px solid $dark-gray-color-10; + color: $white; + justify-content: space-between; + font-size: 1.3em; + padding: 0.5em 1em; + cursor: grab; + + &.drag-active { + cursor: grabbing; + } + } + .studip-dialog-close-button { + @include background-icon(decline, info-alt); + background-repeat: no-repeat; + background-position-y: center; + background-color: transparent; + border: none; + + width: 22px; + height: 22px; + margin-right: -10px; + margin-left: 2em; + cursor: pointer; + } + .studip-dialog-content { + color: $black; + position: relative; + padding: 15px; + overflow-y: auto; + min-width: 100%; + box-sizing: border-box; + } + .studip-dialog-footer { + border-top: 1px solid $dark-gray-color-10; + justify-content: space-between; + } + + &.studip-dialog-warning, + &.studip-dialog-alert { + .studip-dialog-content { + padding: 15px 15px 15px 62px; + background-position: 12px center; + background-repeat: no-repeat; + box-sizing: border-box; + display: flex; + align-items: center; + } + } + + &.studip-dialog-alert { + .studip-dialog-header { + background: $active-color none repeat scroll 0 0; + } + .studip-dialog-content { + @include background-icon(question-circle-full, attention, 32); + } + } + &.studip-dialog-warning { + .studip-dialog-header { + color: $black; + background: $activity-color none repeat scroll 0 0; + } + .studip-dialog-close-button { + @include background-icon(decline, clickable); + border: none; + background-color: transparent; + } + .studip-dialog-content { + @include background-icon(question-circle-full, status-yellow, 32); + } + } + +} +/* * * * * * * * * * * * * +v u e d i a l o g e n d +* * * * * * * * * * * * */ \ No newline at end of file diff --git a/resources/assets/stylesheets/scss/wizard.scss b/resources/assets/stylesheets/scss/wizard.scss new file mode 100644 index 0000000000000000000000000000000000000000..3ad3650912afec31c7a43841ddab5acb7eb11108 --- /dev/null +++ b/resources/assets/stylesheets/scss/wizard.scss @@ -0,0 +1,214 @@ +@import '../mixins'; +.wizard-wrapper { + display: flex; + + .wizard-meta { + width: 270px; + min-height: 440px; + margin-top: 38px; + + img { + margin: auto; + display: block; + } + + p { + margin: 15px; + } + .wizard-requirements { + span { + font-weight: 700; + } + ul { + padding: 4px 0; + li { + list-style: none; + button { + padding: 2px 0; + background-color: transparent; + border: none; + color: $base-color; + cursor: pointer; + &:hover { + color: $red; + } + } + img { + padding-right: 4px; + display: inline-block; + vertical-align: sub; + } + } + } + } + } + .wizard-content-wrapper { + flex-grow: 2; + margin-left: 15px; + + h2 span.required { + color: $red; + } + + .wizard-progress { + list-style: none; + padding: 0; + margin: 1.5em 0 2.5em 0; + + li { + display: inline-block; + position: relative; + margin-right: 60px; + border: solid 2px $base-color; + button { + padding: 6px 0; + height: 36px; + width: 36px; + cursor: pointer; + background: no-repeat; + border: none; + } + &.valid { + background-color: $base-color; + } + &.invalid { + background-color: white; + } + &.optional { + border: dashed thin $base-color; + } + &::before { + position: absolute; + content: ""; + width: 62px; + border: solid thin $base-color; + top: 50%; + transform: translateY(-50%); + -o-transform: translateY(-50%); + -ms-transform: translateY(-50%); + -moz-transform: translateY(-50%); + -webkit-transform: translateY(-50%); + left: 100%; + } + &.active::after { + position: absolute; + content: ""; + width: 38px; + height: 3px; + background: $base-color; + top: 44px; + left: -1px; + } + } + li:last-child { + margin-right: 0; + &::before { + display: none; + } + } + + } + + .wizard-list { + list-style: none; + padding: 0; + .wizard-item { + .wizard-content { + max-width: 555px; + max-height: 475px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: $base-color #f5f5f5; + + .wizard-required { + color: $red; + } + + textarea { + resize: vertical; + } + + input[type="text"]::placeholder, + textarea::placeholder { + color: $dark-gray-color-60; + } + } + } + } + } +} + + +form.default fieldset.radiobutton-set { + > legend { + margin: 0px; + width: 100%; + } + border: none; + padding: 0px; + margin-left: 0px; + margin-right: 0px; + + > input[type=radio] { + opacity: 0; + position: absolute; + &:focus + label { + outline: auto; + } + } + > label { + cursor: pointer; + border: 1px solid $content-color-40; + transition: background-color 200ms; + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px; + padding-bottom: 2px; + margin-bottom: 0; + border-top: none; + :not(.undecorated) { + text-indent: 0; + } + > .text { + width: 100%; + margin-left: 10px; + } + > .unchecked { + margin-right: 0; + } + > .check { + display: none; + } + } + > label:first-of-type { + border-top: 1px solid $content-color-40; + } + > label:last-child::after { + content: none; + } + > div { + border: 1px solid $content-color-40; + border-top: none; + display: none; + padding: 10px; + + } + > input[type=radio]:checked + label { + background-color: $content-color-20; + transition: background-color 200ms; + > .unchecked { + display: none; + } + > .check { + display: inline-block; + } + } + > input[type=radio]:checked + label + div { + display: block; + .description { + animation-duration: 400ms; + animation-name: terms_of_use_fadein; + } + } +} \ No newline at end of file diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index 804a42249824a58faa6e0ee1fe3e24745c8b4595..4cbfbd3c109eddd447c69efb3a6a9412d9ea72d5 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -80,6 +80,10 @@ @import "scss/responsive"; @import "scss/resources"; @import "scss/sidebar"; +@import "scss/tooltip"; +@import "scss/table_of_contents"; +@import "scss/wiki"; +@import "scss/wizard"; @import "scss/select"; @import "scss/selects"; @import "scss/search"; @@ -92,14 +96,11 @@ @import "scss/studygroup"; @import "scss/studip-overlay"; @import "scss/studip-selection"; -@import "scss/table_of_contents"; @import "scss/tabs"; -@import "scss/tooltip"; @import "scss/tfa"; @import "scss/tour"; @import "scss/typography"; @import "scss/user-administration"; -@import "scss/wiki"; @import "scss/multi_person_search"; diff --git a/resources/vue/components/StudipDialog.vue b/resources/vue/components/StudipDialog.vue index 603b8b0494244c0f824069c5c759aaa741add0b2..8d885181227ce7a6e70ec2e7ecabbbe752f161a9 100644 --- a/resources/vue/components/StudipDialog.vue +++ b/resources/vue/components/StudipDialog.vue @@ -61,28 +61,37 @@ <div v-if="alert">{{ alert }}</div> </section> <footer class="studip-dialog-footer" ref="footer"> - <button - v-if="buttonA" - :title="buttonA.text" - :class="[buttonA.class]" - class="button" - type="button" - @click="confirmDialog" - > - {{ buttonA.text }} - </button> - <slot name="dialogButtons"></slot> - <button - v-if="buttonB" - :title="buttonB.text" - :class="[buttonB.class]" - class="button" - type="button" - ref="buttonB" - @click="closeDialog" - > - {{ buttonB.text }} - </button> + <div class="studip-dialog-footer-buttonset-left"> + <slot name="dialogButtonsBefore"></slot> + </div> + <div class="studip-dialog-footer-buttonset-center"> + <button + v-if="buttonA" + :title="buttonA.text" + :class="[buttonA.class]" + :disabled="buttonA.disabled" + class="button" + type="button" + @click="confirmDialog" + > + {{ buttonA.text }} + </button> + <slot name="dialogButtons"></slot> + <button + v-if="buttonB" + :title="buttonB.text" + :class="[buttonB.class]" + class="button" + type="button" + ref="buttonB" + @click="closeDialog" + > + {{ buttonB.text }} + </button> + </div> + <div class="studip-dialog-footer-buttonset-right"> + <slot name="dialogButtonsAfter"></slot> + </div> </footer> </div> </vue-resizeable> @@ -106,11 +115,25 @@ export default { VueResizeable, }, props: { - height: {type: String, default: '300'}, - width: {type: String, default: '450'}, + height: { + type: String, + default: '300' + }, + width: { + type: String, + default: '450' + }, title: String, confirmText: String, closeText: String, + confirmShow: { + type: Boolean, + default: true + }, + confirmDisabled: { + type: Boolean, + default: false + }, confirmClass: String, closeClass: String, question: String, @@ -148,10 +171,11 @@ export default { button.text = this.$gettext('Ja'); button.class = 'accept'; } - if (this.confirmText) { + if (this.confirmText && this.confirmShow) { button = {}; button.text = this.confirmText; button.class = this.confirmClass; + button.disabled = this.confirmDisabled } return button; diff --git a/resources/vue/components/StudipWizardDialog.vue b/resources/vue/components/StudipWizardDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..7f73c1e160b935d6b0989b66d2253fa3c5846322 --- /dev/null +++ b/resources/vue/components/StudipWizardDialog.vue @@ -0,0 +1,254 @@ +<template> + <studip-dialog + :height="height" + :width="width" + :title="title" + :confirmText="confirmText" + :confirmClass="confirmClass" + :confirmDisabled="!showConfirm" + :closeText="closeText" + :closeClass="closeClass" + @close="$emit('close')" + @confirm="confirm" + > + <template v-slot:dialogContent> + <div class="wizard-wrapper"> + <div class="wizard-meta"> + <studip-icon :shape="activeSlot.icon" :size="96"/> + <p class="wizard-description"> + {{ activeSlot.description }} + </p> + <p v-if="requirements.length > 0" class="wizard-requirements"> + <span>{{ $gettext('Bitte geben Sie die folgenden Informationen an:') }}</span> + <ul> + <li v-for="(requirement, index) in requirements" :key="requirement.slot.name + '_' + index"> + <button @click="selectSlot(requirement.slot.id)"> + <studip-icon + :shape="requirement.slot.icon" + :size="16" + role="clickable" + />{{ requirement.text }} + </button> + </li> + </ul> + </p> + </div> + <div class="wizard-content-wrapper"> + <h2> + {{ activeSlot.title }}<span v-if="activeSlotRequiered" aria-hidden="true" class="required">*</span> + </h2> + <ul class="wizard-progress"> + <li + v-for="progress in slots" + :key="progress.id" + :class="[ + isValid(progress.id) ? 'valid' : 'invalid', + activeId === progress.id ? 'active' : 'inactive', + isOptional(progress.id) ? 'optional' : '' + ]" + > + <button + ref="tabs" + :title="progress.title" + role="tab" + :aria-selected="activeId === progress.id" + :aria-controls="progress.name" + :tabindex="0" + @click="selectSlot(progress.id)" + @keydown.right="nextContent" + @keydown.left="prevContent" + > + <studip-icon + :shape="progress.icon" + :size="24" + :role="isValid(progress.id) ? 'info_alt' : 'clickable'" + /> + </button> + </li> + </ul> + <ul class="wizard-list"> + <li v-for="slot in slots" :key="slot.id"> + <div + v-show="slot.id === activeId" + class="wizard-item" + role="tabpanel" + :aria-labelledby="slot.name" + > + <div class="wizard-content"> + <slot :name="slot.name" ></slot> + </div> + </div> + </li> + </ul> + </div> + </div> + </template> + <template v-slot:dialogButtonsBefore> + <button :style="{visibility: hasPrevContent ? 'visible' : 'hidden'}" class="button arr_left" @click="prevContent"> + {{ $gettext('zurück') }} + </button> + </template> + <template v-slot:dialogButtonsAfter> + <button :style="{visibility: hasNextContent ? 'visible' : 'hidden'}" class="button arr_right" @click="nextContent"> + {{ $gettext('weiter') }} + </button> + </template> + </studip-dialog> +</template> + +<script> +import StudipDialog from './StudipDialog.vue' +import StudipIcon from './StudipIcon.vue'; +export default { + name: 'studip-wizard-dialog', + components: { + StudipDialog, + StudipIcon + }, + props: { + title: { + type: String + }, + confirmText: { + type: String + }, + closeText: { + type: String + }, + confirmClass: { + type: String, + default: 'accept' + }, + closeClass: { + type: String, + default: 'cancel' + }, + height: { + type: String, + default: '640' + }, + width: { + type: String, + default: '880' + }, + slots: { + type: Array, + required: true + }, + lastRequiredSlotId: { + type: Number + }, + requirements: { + type: Array, + default: () => [] + } + }, + data() { + return { + activeId: 1, + visitedIds: [1] + } + }, + computed: { + hasPrevContent() { + if (this.activeId === 1) { + return false; + } + + return true; + }, + hasNextContent() { + if (this.activeId === this.slots.length) { + return false; + } + + return true; + }, + showConfirm() { + let valid = true; + if (this.lastRequiredSlotId !== undefined) { + this.slots.every(slot => { + if (slot.id > this.lastRequiredSlotId) { + return false; + } + if (!slot.valid) { + valid = false; + } + + return true; + }); + + return valid; + } + + this.slots.forEach( slot => { + if (!slot.valid) { + valid = false; + } + }); + + return valid; + }, + activeSlot() { + return this.slots.filter(slot => this.activeId === slot.id)[0]; + }, + activeSlotRequiered() { + if (this.lastRequiredSlotId === undefined) { + return false; + } + + return this.lastRequiredSlotId >= this.activeSlot.id; + }, + }, + methods: { + prevContent() { + if (!this.hasPrevContent) { + return; + } else { + this.activeId = this.activeId - 1; + this.$nextTick(() => { + this.$refs.tabs[this.activeId - 1].focus(); + }); + } + }, + nextContent() { + if (!this.hasNextContent) { + return; + } else { + this.activeId = this.activeId + 1; + this.$nextTick(() => { + this.$refs.tabs[this.activeId - 1].focus(); + }); + } + }, + selectSlot(id) { + this.activeId = id; + }, + isValid(id) { + const slot = this.slots.find( slot => slot.id === id); + if (slot) { + return slot.valid && this.visitedIds.indexOf(id) !== -1; + } + + return false; + }, + isOptional(id) { + if (this.lastRequiredSlotId === undefined) { + return false; + } + + return this.lastRequiredSlotId < id; + }, + confirm() { + this.$emit('confirm'); + } + }, + watch: { + activeId(newVal) { + if (this.visitedIds.indexOf(newVal) === -1) { + this.visitedIds.push(newVal); + } + } + } +} +</script> diff --git a/resources/vue/components/courseware/ActivitiesApp.vue b/resources/vue/components/courseware/ActivitiesApp.vue new file mode 100644 index 0000000000000000000000000000000000000000..7bf8e238c941ddaaf59ae0c2ba9ad7a562b07fda --- /dev/null +++ b/resources/vue/components/courseware/ActivitiesApp.vue @@ -0,0 +1,31 @@ +<template> + <div class="cw-activities-wrapper"> + <courseware-activities /> + <MountingPortal mountTo="#courseware-activities-widget-filter-type" name="sidebar-filter-type"> + <courseware-activities-widget-filter-type /> + </MountingPortal> + <MountingPortal mountTo="#courseware-activities-widget-filter-unit" name="sidebar-filter-unit"> + <courseware-activities-widget-filter-unit /> + </MountingPortal> + </div> +</template> + +<script> +import CoursewareActivities from './CoursewareActivities.vue'; +import CoursewareActivitiesWidgetFilterType from './CoursewareActivitiesWidgetFilterType.vue'; +import CoursewareActivitiesWidgetFilterUnit from './CoursewareActivitiesWidgetFilterUnit.vue'; +import { mapGetters } from 'vuex'; + +export default { + components: { + CoursewareActivities, + CoursewareActivitiesWidgetFilterType, + CoursewareActivitiesWidgetFilterUnit + }, + computed: { + ...mapGetters({ + userIsTeacher: 'userIsTeacher', + }), + }, +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/AdminApp.vue b/resources/vue/components/courseware/AdminApp.vue index 01bde3867f92bec7ca50b37501d6d0df9fe0dd85..d210558b18b94dfb6a663dab72c7d91136fcefcd 100644 --- a/resources/vue/components/courseware/AdminApp.vue +++ b/resources/vue/components/courseware/AdminApp.vue @@ -15,6 +15,8 @@ import CoursewareAdminActionWidget from './CoursewareAdminActionWidget.vue'; import CoursewareAdminTemplates from './CoursewareAdminTemplates.vue'; import CoursewareAdminViewWidget from './CoursewareAdminViewWidget.vue'; +import { mapGetters, mapActions } from 'vuex'; + export default { components: { CoursewareAdminActionWidget, @@ -22,9 +24,9 @@ export default { CoursewareAdminViewWidget }, computed: { - adminViewMode() { - return this.$store.getters.adminViewMode; - }, + ...mapGetters({ + adminViewMode: 'adminViewMode' + }), templatesView() { return this.adminViewMode === 'templates'; }, diff --git a/resources/vue/components/courseware/ContentOverviewApp.vue b/resources/vue/components/courseware/ContentOverviewApp.vue deleted file mode 100644 index 831a2e76cad15fcda6c668ffa3101be61da213d0..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/ContentOverviewApp.vue +++ /dev/null @@ -1,25 +0,0 @@ -<template> - <div class="cw-content-overview"> - <courseware-content-overview-elements /> - <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-filters"> - <courseware-content-overview-filter-widget /> - </MountingPortal> - </div> -</template> - -<script> -import CoursewareContentOverviewElements from './CoursewareContentOverviewElements.vue'; -import CoursewareContentOverviewActionWidget from './CoursewareContentOverviewActionWidget.vue'; -import CoursewareContentOverviewFilterWidget from './CoursewareContentOverviewFilterWidget.vue'; - -export default { - components: { - CoursewareContentOverviewElements, - CoursewareContentOverviewActionWidget, - CoursewareContentOverviewFilterWidget - } -} -</script> diff --git a/resources/vue/components/courseware/CoursewareActionWidget.vue b/resources/vue/components/courseware/CoursewareActionWidget.vue index 8dbb7e48c915fe036075021839337796d3b372f2..172d2ae519266724405a42bceed65d71539192fb 100644 --- a/resources/vue/components/courseware/CoursewareActionWidget.vue +++ b/resources/vue/components/courseware/CoursewareActionWidget.vue @@ -2,54 +2,14 @@ <sidebar-widget :title="$gettext('Aktionen')" v-if="structuralElement"> <template #content> <ul class="widget-list widget-links cw-action-widget"> - <li class="cw-action-widget-show-toc"> - <button @click="toggleTOC"> - {{ tocText }} - </button> - </li> - <li class="cw-action-widget-show-consume-mode"> - <button @click="showConsumeMode"> - {{ $gettext('Fokusmodus einschalten') }} - </button> - </li> - <li v-if="canEdit && !blockedByAnotherUser" class="cw-action-widget-edit"> - <button @click="editElement"> - {{ $gettext('Seite bearbeiten') }} - </button> - </li> - <li v-if="canEdit && blockedByAnotherUser && userIsTeacher" class="cw-action-widget-remove-lock"> - <button @click="removeElementLock"> - {{ $gettext('Sperre aufheben') }} - </button> - </li> <li v-if="canEdit" class="cw-action-widget-add"> <button @click="addElement"> {{ $gettext('Seite hinzufügen') }} </button> </li> - <li class="cw-action-widget-info"> - <button @click="showElementInfo"> - {{ $gettext('Informationen anzeigen') }} - </button> - </li> - <li class="cw-action-widget-star"> - <button @click="createBookmark"> - {{ $gettext('Lesezeichen setzen') }} - </button> - </li> - <li v-if="context.type === 'users'" class="cw-action-widget-link"> + <li v-if="inCourseContext && userIsTeacher" class="cw-action-widget-link"> <button @click="linkElement"> - {{ $gettext('Öffentlichen Link erzeugen') }} - </button> - </li> - <li v-if="!isOwner" class="cw-action-widget-oer"> - <button @click="suggestOER"> - <translate>Material für den OER Campus vorschlagen</translate> - </button> - </li> - <li v-if="!isRoot && canEdit && !blockedByAnotherUser" class="cw-action-widget-trash"> - <button @click="deleteElement"> - {{ $gettext('Seite löschen') }} + {{ $gettext('Seite verknüpfen') }} </button> </li> </ul> @@ -59,36 +19,19 @@ <script> import SidebarWidget from '../SidebarWidget.vue'; -import CoursewareExport from '@/vue/mixins/courseware/export.js'; import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-action-widget', - props: ['structuralElement', 'canVisit'], + props: ['structuralElement'], components: { SidebarWidget, }, - mixins: [CoursewareExport], computed: { ...mapGetters({ - userId: 'userId', - userIsTeacher: 'userIsTeacher', - consumeMode: 'consumeMode', - showToolbar: 'showToolbar', context: 'context', - - blocked: 'currentElementBlocked', - blockerId: 'currentElementBlockerId', - blockedByThisUser: 'currentElementBlockedByThisUser', - blockedByAnotherUser: 'currentElementBlockedByAnotherUser', + userIsTeacher: 'userIsTeacher', }), - isRoot() { - if (!this.structuralElement) { - return true; - } - - return this.structuralElement.relationships.parent.data === null; - }, canEdit() { if (!this.structuralElement) { return false; @@ -98,96 +41,21 @@ export default { currentId() { return this.structuralElement?.id; }, - tocText() { - return this.showToolbar ? this.$gettext('Inhaltsverzeichnis ausblenden') : this.$gettext('Inhaltsverzeichnis anzeigen'); - }, - isTask() { - return this.structuralElement?.relationships.task.data !== null; - }, - isOwner() { - return this.structuralElement.relationships.owner.data.id === this.userId; + inCourseContext() { + return this.context.type === 'courses'; } }, methods: { ...mapActions({ - showElementEditDialog: 'showElementEditDialog', showElementAddDialog: 'showElementAddDialog', - showElementDeleteDialog: 'showElementDeleteDialog', - showElementInfoDialog: 'showElementInfoDialog', showElementLinkDialog: 'showElementLinkDialog', - showElementRemoveLockDialog: 'showElementRemoveLockDialog', - updateShowSuggestOerDialog: 'updateShowSuggestOerDialog', - companionInfo: 'companionInfo', - addBookmark: 'addBookmark', - lockObject: 'lockObject', - setConsumeMode: 'coursewareConsumeMode', - setViewMode: 'coursewareViewMode', - setShowToolbar: 'coursewareShowToolbar', - setSelectedToolbarItem: 'coursewareSelectedToolbarItem', - loadStructuralElement: 'loadStructuralElement', }), - async editElement() { - await this.loadStructuralElement(this.currentId); - if (this.blockedByAnotherUser) { - this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); - - return false; - } - try { - await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); - } catch(error) { - if (error.status === 409) { - this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); - } else { - console.log(error); - } - - return false; - } - this.showElementEditDialog(true); - }, - async removeElementLock() { - this.showElementRemoveLockDialog(true); - }, - async deleteElement() { - await this.loadStructuralElement(this.currentId); - if (this.blockedByAnotherUser) { - this.companionInfo({ - info: this.$gettextInterpolate( - this.$gettext('Löschen nicht möglich, da %{blockingUserName} die Seite bearbeitet.'), - {blockingUserName: this.blockingUserName} - ) - }); - - return false; - } - await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); - this.showElementDeleteDialog(true); - }, addElement() { this.showElementAddDialog(true); }, - showElementInfo() { - this.showElementInfoDialog(true); - }, - createBookmark() { - this.addBookmark(this.structuralElement); - this.companionInfo({ info: this.$gettext('Das Lesezeichen wurde gesetzt.') }); - }, - toggleTOC() { - this.setShowToolbar(!this.showToolbar); - }, - showConsumeMode() { - this.setViewMode('read'); - this.setSelectedToolbarItem('contents'); - this.setConsumeMode(true); - }, - suggestOER() { - this.updateShowSuggestOerDialog(true); - }, linkElement() { this.showElementLinkDialog(true); - } + }, }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareActivities.vue b/resources/vue/components/courseware/CoursewareActivities.vue new file mode 100644 index 0000000000000000000000000000000000000000..aab705f4ee9b15937219f693f59190882fd1c22d --- /dev/null +++ b/resources/vue/components/courseware/CoursewareActivities.vue @@ -0,0 +1,124 @@ +<template> + <section class="contentbox"> + <header><h1>{{ $gettext('Aktivitäten') }}</h1></header> + <section> + <studip-progress-indicator + v-show="loading" + :description="$gettext('Lade Aktivitäten…')" + /> + <courseware-companion-box + v-if="filteredActivitiesList.length === 0 && !loading" + mood="sad" + :msgCompanion="$gettext('Es wurden keine Aktivitäten gefunden.')" + /> + <ul class="cw-activities"> + <courseware-activity-item v-for="(item, index) in filteredActivitiesList" :key="index" :item="item" /> + </ul> + </section> + </section> +</template> + +<script> +import CoursewareActivityItem from './CoursewareActivityItem.vue'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import StudipProgressIndicator from '../StudipProgressIndicator.vue'; + +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-activities', + components: { + CoursewareActivityItem, + CoursewareCompanionBox, + StudipProgressIndicator, + }, + data() { + return { + activitiesList: [], + loading: false + } + }, + computed: { + ...mapGetters({ + userId: 'userId', + getUserById: 'users/byId', + context: 'context', + getStructuralElementById: 'courseware-structural-elements/byId', + getCoursewareUnitById: 'courseware-units/byId', + coursewareUnits: 'courseware-units/all', + + typeFilter: 'typeFilter', + unitFilter: 'unitFilter' + }), + filteredActivitiesList() { + let list = this.activitiesList.slice().sort((a,b) => b.timestamp - a.timestamp); + if (['edited', 'created', 'answered', 'interacted', 'voided',].includes(this.typeFilter)) { + list = list.filter(activity => activity.type === this.typeFilter); + } + if (this.unitFilter !== 'all') { + list = list.filter(activity => activity.unitId === this.unitFilter); + } + + return list; + }, + }, + mounted() { + this.getActivities(); + }, + methods: { + ...mapActions({ + loadCoursewareActivities: 'loadCoursewareActivities', + loadStructuralElementById: 'courseware-structural-elements/loadById', + }), + + async loadActivitiesElements(activities) { + const results = []; + for (const activity of activities) { + const structuralElementId = activity.relationships.object.meta["object-id"]; + results.push(this.loadStructuralElementById({id: structuralElementId, options: { include: 'ancestors'} })); + } + // activity might contain structural element hidden for current user + return Promise.all(results).catch(e => { if (e.status !== 403) { console.error(e); } }); + }, + + async getActivities() { + this.loading = true; + let activities = await this.loadCoursewareActivities({ userId: this.userId, courseId: this.context.id}); + this.activitiesList = []; + + await this.loadActivitiesElements(activities); + + for (const activity of activities) { + let error = false; + let username = this.getUserById({ id: activity.relationships.actor.data.id }).attributes['formatted-name']; + const date = new Date(activity.attributes.mkdate); + const structuralElementId = activity.relationships.object.meta["object-id"]; + + const activityStructuralElement = this.getStructuralElementById({ id: structuralElementId }); + if (activityStructuralElement === undefined || !activityStructuralElement.attributes['can-visit']) { + error = true; + } + if (!error) { + const unitId = activityStructuralElement.relationships?.unit?.data?.id ?? null; + const unit = this.getCoursewareUnitById({id: unitId }); + let options = { year: 'numeric', month: '2-digit', day: '2-digit' }; + let data = { + username: username, + timestamp: date.getTime(), + readableDate: date.toLocaleString('de-DE', options), + type: activity.attributes.verb, + title: activity.attributes.title, + elementId: structuralElementId, + unitId: unitId, + unit: unit, + contextId: activity.relationships.context.data.id, + content: activity.attributes.content + } + this.activitiesList.push(data); + } + } + this.loading = false; + } + } +}; +</script> diff --git a/resources/vue/components/courseware/CoursewareActivitiesWidgetFilterType.vue b/resources/vue/components/courseware/CoursewareActivitiesWidgetFilterType.vue new file mode 100644 index 0000000000000000000000000000000000000000..0c57271b6441d96d27d031c9471d600f8aa055b4 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareActivitiesWidgetFilterType.vue @@ -0,0 +1,61 @@ +<template> + <sidebar-widget :title="$gettext('Aktivitäten')"> + <template #content> + <div class="cw-filter-widget"> + <form class="default" @submit.prevent=""> + <select v-model="typeFilter"> + <option value="all"> + {{ $gettext('Alle') }} + </option> + <option value="edited"> + {{ $gettext('Bearbeitet') }} + </option> + <option value="created"> + {{ $gettext('Erstellt') }} + </option> + <option value="answered"> + {{ $gettext('Feedback') }} + </option> + <option value="interacted"> + {{ $gettext('Kommentiert') }} + </option> + <option value="voided"> + {{ $gettext('Gelöscht') }} + </option> + </select> + </form> + </div> + </template> + </sidebar-widget> +</template> + +<script> +import SidebarWidget from '../SidebarWidget.vue'; + +import { mapActions } from 'vuex'; + +export default { + name: 'courseware-activities-widget-filter-type', + components: { + SidebarWidget + }, + data() { + return { + typeFilter: 'all', + }; + }, + methods: { + ...mapActions({ + setTypeFilter: 'setTypeFilter', + }), + filterType() { + this.setTypeFilter(this.typeFilter); + }, + }, + watch: { + typeFilter() { + this.filterType(); + }, + } +} +</script> diff --git a/resources/vue/components/courseware/CoursewareActivitiesWidgetFilterUnit.vue b/resources/vue/components/courseware/CoursewareActivitiesWidgetFilterUnit.vue new file mode 100644 index 0000000000000000000000000000000000000000..d0142ea0077ad3e331bf86363b8cf8c1ef1225b8 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareActivitiesWidgetFilterUnit.vue @@ -0,0 +1,58 @@ +<template> + <sidebar-widget :title="$gettext('Lernmaterial')"> + <template #content> + <div class="cw-filter-widget"> + <form class="default" @submit.prevent=""> + <select v-model="unitFilter"> + <option value="all"> + {{ $gettext('Alle') }} + </option> + <option v-for="unit in coursewareUnits" :key="unit.id" :value="unit.id"> + {{ getUnitTitle(unit) }} + </option> + </select> + </form> + </div> + </template> + </sidebar-widget> +</template> + +<script> +import SidebarWidget from '../SidebarWidget.vue'; + +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-activities-widget-filter-unit', + components: { + SidebarWidget + }, + data() { + return { + unitFilter: 'all' + }; + }, + computed: { + ...mapGetters({ + getStructuralElementById: 'courseware-structural-elements/byId', + coursewareUnits: 'courseware-units/all', + }), + }, + methods: { + ...mapActions({ + setUnitFilter: 'setUnitFilter', + }), + filterUnit() { + this.setUnitFilter(this.unitFilter); + }, + getUnitTitle(unit) { + return this.getStructuralElementById({id: unit.relationships['structural-element'].data.id }).attributes.title; + } + }, + watch: { + unitFilter() { + this.filterUnit(); + } + } +} +</script> diff --git a/resources/vue/components/courseware/CoursewareActivityItem.vue b/resources/vue/components/courseware/CoursewareActivityItem.vue index 8a7a4a9cb6746103b714014d1553d2826713af4b..9520d36047f073a5a51eb00fd0d249cf6f9e0024 100644 --- a/resources/vue/components/courseware/CoursewareActivityItem.vue +++ b/resources/vue/components/courseware/CoursewareActivityItem.vue @@ -3,14 +3,14 @@ <p v-if="item.username" class="cw-activity-item-user"> <a :href="userUrl"><studip-icon role="inactive" shape="headache" />{{ item.username }}</a> </p> - <p v-if="item.date" class="cw-activity-item-date"> - <studip-icon role="inactive" shape="timetable" />{{ item.date }} + <p v-if="item.readableDate" class="cw-activity-item-date"> + <studip-icon role="inactive" shape="timetable" />{{ item.readableDate }} </p> <p class="cw-activity-item-element"> - <a :href="linkUrl" :title="item.complete_breadcrumb"><studip-icon role="inactive" :shape="shape" />{{ item.element_breadcrumb }}</a> + <a :href="linkUrl" :title="elementTitle"><studip-icon role="inactive" shape="content2" />{{ unitTitle }} | {{ breadcrumb }}</a> </p> - <p v-if="text" class="cw-activity-item-text"> - <span v-html="text"></span> + <p v-if="content" class="cw-activity-item-content"> + <studip-icon role="inactive" :shape="shape" /><span v-html="content"></span> </p> </li> </template> @@ -18,6 +18,8 @@ <script> import StudipIcon from './../StudipIcon.vue'; +import { mapGetters } from 'vuex'; + export default { name: 'courseware-activity-item', components: { @@ -27,27 +29,22 @@ export default { item: Object, }, computed: { - text() { + ...mapGetters({ + context: 'context', + getStructuralElementById: 'courseware-structural-elements/byId', + }), + content() { if (this.item.content == null || this.item.content == '') { - return this.item.text; + return this.item.title; } - switch (this.item.type) { - case 'interacted': - return this.item.username + ' commented: ' + this.item.content; //TODO: Localization - case 'answered': - return this.item.username + ' added feedback: ' + this.item.content; //TODO: Localization - default: - return this.item.text; - } + return this.item.content; }, - userUrl() { return STUDIP.URLHelper.base_url + 'dispatch.php/profile?username=' + this.item.username; }, - linkUrl() { - return STUDIP.URLHelper.base_url + 'dispatch.php/course/courseware/?cid=' + this.item.context_id + '#/structural_element/' + this.item.element_id; + return STUDIP.URLHelper.base_url + 'dispatch.php/course/courseware/courseware/' + this.item.unitId + '?cid=' + this.item.contextId + '#/structural_element/' + this.item.elementId; }, shape() { switch (this.item.type) { @@ -63,6 +60,39 @@ export default { return 'question-circle-full'; } }, + breadcrumb() { + let breadcrumb = this.element.attributes.title; + let currentStructuralElement = this.element; + let i = 1; //max breadcrumb navigation depth check + while (currentStructuralElement.relationships.parent.data !== null) { + let parentId = currentStructuralElement.relationships.parent.data.id; + currentStructuralElement = this.getStructuralElementById({ id: parentId }); + if (currentStructuralElement === undefined) { + break; + } + if (++i <= 3) { + breadcrumb = currentStructuralElement.attributes.title + '/' + breadcrumb; + if (currentStructuralElement.relationships.parent.data !== null && i === 3) { + breadcrumb = '.../' + breadcrumb; + } + } + } + + return breadcrumb; + }, + element() { + return this.getStructuralElementById({ id: this.item.elementId }); + }, + elementTitle() { + return this.element?.attributes?.title ?? this.$gettext('unbekannt'); + }, + unitTitle() { + if (this.item.unit) { + return this.getStructuralElementById({id: this.item.unit.relationships['structural-element'].data.id }).attributes.title; + } + + return '-'; + } }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareAdminActionWidget.vue b/resources/vue/components/courseware/CoursewareAdminActionWidget.vue index 9a8ce6128b12592cd0155198ba3ddc829378cfd8..6c02699650e97cd40ddb8caf91c7e5f88ea5d156 100644 --- a/resources/vue/components/courseware/CoursewareAdminActionWidget.vue +++ b/resources/vue/components/courseware/CoursewareAdminActionWidget.vue @@ -9,23 +9,25 @@ </template> <script> +import { mapGetters, mapActions } from 'vuex'; export default { name: 'courseware-admin-action-widget', computed: { - adminViewMode() { - return this.$store.getters.adminViewMode; - }, + ...mapGetters({ + adminViewMode: 'adminViewMode', + showAddTemplateDialog: 'showAddTemplateDialog' + }), templatesView() { return this.adminViewMode === 'templates'; - }, - showAddTemplateDialog() { - return this.$store.getters.showAddTemplateDialog; - }, + } }, methods: { + ...mapActions({ + setShowAddTemplateDialog: 'showAddTemplateDialog' + }), addTemplate() { - this.$store.dispatch('showAddTemplateDialog', true); + this.setShowAddTemplateDialog(true); } } } diff --git a/resources/vue/components/courseware/CoursewareAdminTemplates.vue b/resources/vue/components/courseware/CoursewareAdminTemplates.vue index b3f330c708fe76320c51ce45b720531eda8add49..a0b17e6a60c583bef4f5247dfaba98519cf3942a 100644 --- a/resources/vue/components/courseware/CoursewareAdminTemplates.vue +++ b/resources/vue/components/courseware/CoursewareAdminTemplates.vue @@ -151,10 +151,11 @@ export default { ...mapActions({ createTemplate: 'courseware-templates/create', updateTemplate: 'courseware-templates/update', - deleteTemplate: 'courseware-templates/delete' + deleteTemplate: 'courseware-templates/delete', + setShowAddTemplateDialog: 'showAddTemplateDialog' }), closeAddDialog() { - this.$store.dispatch('showAddTemplateDialog', false); + this.setShowAddTemplateDialog(false); this.newTemplateName = ''; this.newElementPurpose = ''; this.importZip = null; diff --git a/resources/vue/components/courseware/CoursewareAdminViewWidget.vue b/resources/vue/components/courseware/CoursewareAdminViewWidget.vue index cc208f69d581dcd726ae1d7fc63bd99cfdeab9a4..81a80715e37198c9094cbd4c3b81e22b170f8d52 100644 --- a/resources/vue/components/courseware/CoursewareAdminViewWidget.vue +++ b/resources/vue/components/courseware/CoursewareAdminViewWidget.vue @@ -9,22 +9,25 @@ </template> <script> +import { mapGetters, mapActions } from 'vuex'; export default { name: 'courseware-admin-view-widget', computed: { - adminViewMode() { - return this.$store.getters.adminViewMode; - }, + ...mapGetters({ + adminViewMode: 'adminViewMode' + }), templatesView() { return this.adminViewMode === 'templates'; }, }, methods: { + ...mapActions({ + setAdminViewMode: 'adminViewMode' + }), setTemplatesView() { - this.$store.dispatch('adminViewMode', 'templates'); + this.setAdminViewMode('templates'); }, } - } </script> diff --git a/resources/vue/components/courseware/CoursewareBlockAdderArea.vue b/resources/vue/components/courseware/CoursewareBlockAdderArea.vue index 7bc25136e70b9878e0783da1a40908a42007f215..fc9b41f6a564aeeec0419fb860b313234198c015 100644 --- a/resources/vue/components/courseware/CoursewareBlockAdderArea.vue +++ b/resources/vue/components/courseware/CoursewareBlockAdderArea.vue @@ -14,6 +14,8 @@ <script> import StudipIcon from '../StudipIcon.vue'; +import { mapActions, mapGetters } from 'vuex'; + export default { components: { StudipIcon }, name: 'courseware-block-adder-area', @@ -27,23 +29,28 @@ export default { }; }, computed: { + ...mapGetters({ + adderStorage: 'blockAdder', + }), adderDisable() { - return Object.keys(this.$store.getters.blockAdder).length !== 0 && !this.adderActive; - }, - adderStorage() { - return this.$store.getters.blockAdder; + return Object.keys(this.adderStorage).length !== 0 && !this.adderActive; }, }, methods: { + ...mapActions({ + coursewareBlockAdder: 'coursewareBlockAdder', + coursewareSelectedToolbarItem: 'coursewareSelectedToolbarItem', + coursewareShowToolbar: 'coursewareShowToolbar' + }), selectBlockAdder() { if (this.adderActive) { this.adderActive = false; - this.$store.dispatch('coursewareBlockAdder', {}); + this.coursewareBlockAdder({}); } else { this.adderActive = true; - this.$store.dispatch('coursewareBlockAdder', { container: this.container, section: this.section }); - this.$store.dispatch('coursewareSelectedToolbarItem', 'blockadder'); - this.$store.dispatch('coursewareShowToolbar', true); + this.coursewareBlockAdder({ container: this.container, section: this.section }); + this.coursewareSelectedToolbarItem('blockadder'); + this.coursewareShowToolbar(true); } }, }, diff --git a/resources/vue/components/courseware/CoursewareBlockComments.vue b/resources/vue/components/courseware/CoursewareBlockComments.vue index fb66627a07e35b499f148e0d03ca2ae0afacd58a..8e18909cc3db47519d421f1c58687d0f4cec6475 100644 --- a/resources/vue/components/courseware/CoursewareBlockComments.vue +++ b/resources/vue/components/courseware/CoursewareBlockComments.vue @@ -18,7 +18,7 @@ <script> import CoursewareTalkBubble from './CoursewareTalkBubble.vue'; -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-block-comments', @@ -57,12 +57,16 @@ export default { } }, methods: { + ...mapActions({ + createComments: 'courseware-block-comments/create', + loadRelatedComments: 'courseware-block-comments/loadRelated' + }), async loadComments() { const parent = { type: this.block.type, id: this.block.id, }; - await this.$store.dispatch('courseware-block-comments/loadRelated', { + await this.loadRelatedComments({ parent, relationship: 'comments', options: { @@ -85,7 +89,7 @@ export default { } }; - await this.$store.dispatch('courseware-block-comments/create', data); + await this.createComments(data); this.loadComments(); this.createComment = ''; }, diff --git a/resources/vue/components/courseware/CoursewareBlockEdit.vue b/resources/vue/components/courseware/CoursewareBlockEdit.vue index 55b8b3a1b9b06a4557b5ff4d16681f973b556e1e..a218ab52c4ebcc304518a92b45cb5b272568ff2b 100644 --- a/resources/vue/components/courseware/CoursewareBlockEdit.vue +++ b/resources/vue/components/courseware/CoursewareBlockEdit.vue @@ -14,6 +14,8 @@ </template> <script> +import { mapActions, mapGetters } from 'vuex'; + export default { name: 'courseware-block-edit', props: { @@ -29,14 +31,17 @@ export default { this.originalBlock = this.block; }, methods: { + ...mapActions({ + coursewareBlockAdder: 'coursewareBlockAdder', + coursewareShowToolbar: 'coursewareShowToolbar' + }), deactivateToolbar() { - this.$store.dispatch('coursewareBlockAdder', {}); - this.$store.dispatch('coursewareShowToolbar', false); + this.coursewareBlockAdder({}); + this.coursewareShowToolbar(false); }, }, beforeDestroy() { if (this.exitHandler) { - console.log('autosave'); this.$emit('store'); } } diff --git a/resources/vue/components/courseware/CoursewareBlockFeedback.vue b/resources/vue/components/courseware/CoursewareBlockFeedback.vue index 312eeb9caa4eeab1bc84594430a43dffd4fef95a..fe9e32bf1a66d05537d79967dd82018891d76107 100644 --- a/resources/vue/components/courseware/CoursewareBlockFeedback.vue +++ b/resources/vue/components/courseware/CoursewareBlockFeedback.vue @@ -28,7 +28,7 @@ <script> import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareTalkBubble from './CoursewareTalkBubble.vue'; -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; export default { @@ -68,6 +68,10 @@ export default { } }, methods: { + ...mapActions({ + createFeedback: 'courseware-block-feedback/create', + loadRelatedFeedback: 'courseware-block-feedback/loadRelated', + }), async postFeedback() { this.createFeedback({ blockId: this.block.id, feedback: this.feedbackText }); this.feedbackText = ''; @@ -90,7 +94,7 @@ export default { type: this.block.type, id: this.block.id, }; - await this.$store.dispatch('courseware-block-feedback/loadRelated', { + await this.loadRelatedFeedback({ parent, relationship: 'feedback', options: { @@ -112,7 +116,7 @@ export default { }, }, }; - await this.$store.dispatch('courseware-block-feedback/create', data, { root: true }); + await this.createFeedback(data, { root: true }); this.loadFeedback(); } }, diff --git a/resources/vue/components/courseware/CoursewareBlockInfo.vue b/resources/vue/components/courseware/CoursewareBlockInfo.vue index 1863c16122503e93f1ff5be72943192d775b2031..5ef6e975d49b822feef298d0c069f11773143a3f 100644 --- a/resources/vue/components/courseware/CoursewareBlockInfo.vue +++ b/resources/vue/components/courseware/CoursewareBlockInfo.vue @@ -31,6 +31,7 @@ <script> import IsoDate from './IsoDate.vue'; +import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-block-info', @@ -39,8 +40,11 @@ export default { block: Object, }, computed: { + ...mapGetters({ + relatedUsers: 'users/related', + }), owner() { - const owner = this.$store.getters['users/related']({ + const owner = this.relatedUsers({ parent: this.block, relationship: 'owner', }); @@ -49,7 +53,7 @@ export default { }, editor() { - const editor = this.$store.getters['users/related']({ + const editor = this.relatedUsers({ parent: this.block, relationship: 'editor', }); diff --git a/resources/vue/components/courseware/CoursewareBlockadderItem.vue b/resources/vue/components/courseware/CoursewareBlockadderItem.vue index c882cd2569682c9e71c2234b5ba83794dbd348a1..93c31f424debd7161784dbc8358d891b04072e0e 100644 --- a/resources/vue/components/courseware/CoursewareBlockadderItem.vue +++ b/resources/vue/components/courseware/CoursewareBlockadderItem.vue @@ -30,19 +30,20 @@ export default { computed: { ...mapGetters({ blockAdder: 'blockAdder', - blockById: 'courseware-blocks/byId' + blockById: 'courseware-blocks/byId', + lastCreatedBlock: 'courseware-blocks/lastCreated', }), }, methods: { ...mapActions({ - createBlock: 'createBlockInContainer', companionInfo: 'companionInfo', - companionWarning: 'companionWarning', companionSuccess: 'companionSuccess', - updateContainer: 'updateContainer', + companionWarning: 'companionWarning', + createBlock: 'createBlockInContainer', lockObject: 'lockObject', unlockObject: 'unlockObject', loadBlock: 'courseware-blocks/loadById', + updateContainer: 'updateContainer', }), async addBlock() { if (Object.keys(this.blockAdder).length !== 0) { @@ -55,7 +56,7 @@ export default { blockType: this.type, }); //get new Block - const newBlock = this.$store.getters['courseware-blocks/lastCreated']; + const newBlock = this.lastCreatedBlock; // update container information -> new block id in sections let container = this.blockAdder.container; container.attributes.payload.sections[this.blockAdder.section].blocks.push(newBlock.id); diff --git a/resources/vue/components/courseware/CoursewareCanvasBlock.vue b/resources/vue/components/courseware/CoursewareCanvasBlock.vue index 31c9258f50c81b4d18ac00d611af18b03e3f36ec..8e4bb6513488c98ce7da998d0596c277acf4dba8 100644 --- a/resources/vue/components/courseware/CoursewareCanvasBlock.vue +++ b/resources/vue/components/courseware/CoursewareCanvasBlock.vue @@ -272,6 +272,7 @@ export default { createFile: 'createFile', companionSuccess: 'companionSuccess', companionError: 'companionError', + updateUserDataFields: 'courseware-user-data-fields/update' }), initCurrentData() { this.currentTitle = this.title; @@ -551,7 +552,7 @@ export default { data.attributes.payload.canvas_draw.clickTool = JSON.stringify(this.clickTool); data.attributes.payload.canvas_draw.Text = JSON.stringify(this.Text); - await this.$store.dispatch('courseware-user-data-fields/update', data); + await this.updateUserDataFields(data); }, storeBlock() { let attributes = {}; diff --git a/resources/vue/components/courseware/CoursewareCompanionBox.vue b/resources/vue/components/courseware/CoursewareCompanionBox.vue index f8dbbde812ef5b63d47052b185a5a4bdd1649ad1..2e826956106f1070f93b9ecae52e52bd8bcf9525 100644 --- a/resources/vue/components/courseware/CoursewareCompanionBox.vue +++ b/resources/vue/components/courseware/CoursewareCompanionBox.vue @@ -16,7 +16,7 @@ export default { type: String, default: 'default', validator: value => { - return ['default','unsure', 'special', 'sad', 'pointing'].includes(value); + return ['default','unsure', 'special', 'sad', 'pointing', 'curious'].includes(value); } } }, diff --git a/resources/vue/components/courseware/CoursewareCompanionOverlay.vue b/resources/vue/components/courseware/CoursewareCompanionOverlay.vue index 5c4fc97c8f9a13e9ec072478d19bc981d40bc46e..9cce904e758657fe1487259b39846f3d361f27a4 100644 --- a/resources/vue/components/courseware/CoursewareCompanionOverlay.vue +++ b/resources/vue/components/courseware/CoursewareCompanionOverlay.vue @@ -9,7 +9,7 @@ </template> <script> -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-companion-overlay', @@ -22,8 +22,11 @@ export default { }), }, methods: { + ...mapActions({ + coursewareShowCompanionOverlay: 'coursewareShowCompanionOverlay' + }), hideCompanion() { - this.$store.dispatch('coursewareShowCompanionOverlay', false); + this.coursewareShowCompanionOverlay(false); }, }, watch: { diff --git a/resources/vue/components/courseware/CoursewareConfirmBlock.vue b/resources/vue/components/courseware/CoursewareConfirmBlock.vue index 6d37a576d4bdc0bb803ca651a92bc8d081207210..2a3f9b5a4dd26abcf1c015f456283ff2bb23d58b 100644 --- a/resources/vue/components/courseware/CoursewareConfirmBlock.vue +++ b/resources/vue/components/courseware/CoursewareConfirmBlock.vue @@ -79,6 +79,7 @@ export default { methods: { ...mapActions({ updateBlock: 'updateBlockInContainer', + updateUserDataFields: 'courseware-user-data-fields/update' }), initCurrentData() { this.currentText = this.text; @@ -99,7 +100,7 @@ export default { data.relationships.block.data.id = this.block.id; data.relationships.block.data.type = this.block.type; - await this.$store.dispatch('courseware-user-data-fields/update', data); + await this.updateUserDataFields(data); this.userProgress = 1; this.confirm = true; }, diff --git a/resources/vue/components/courseware/CoursewareContentOverviewActionWidget.vue b/resources/vue/components/courseware/CoursewareContentOverviewActionWidget.vue deleted file mode 100644 index e98976efea0f4090210f39b3256a30c18555668c..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/CoursewareContentOverviewActionWidget.vue +++ /dev/null @@ -1,25 +0,0 @@ -<template> - <ul class="widget-list widget-links cw-action-widget"> - <li class="cw-action-widget-add" > - <a href="#" @click.prevent="addElement"> - <translate>Neues Lernmaterial anlegen</translate> - </a> - </li> - </ul> -</template> - -<script> -import { mapActions } from 'vuex'; - -export default { - name: 'courseware-content-overview-action-widget', - methods: { - ...mapActions({ - setShowOverviewElementAddDialog: 'setShowOverviewElementAddDialog' - }), - addElement() { - this.setShowOverviewElementAddDialog(true); - } - } -} -</script> diff --git a/resources/vue/components/courseware/CoursewareContentOverviewElements.vue b/resources/vue/components/courseware/CoursewareContentOverviewElements.vue deleted file mode 100644 index 0e6a87fde0c647c07003051eb77d2e2e6f1edfcc..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/CoursewareContentOverviewElements.vue +++ /dev/null @@ -1,622 +0,0 @@ -<template> - <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']" - > - <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> - </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> - <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" - > - <template #open-indicator="selectAttributes"> - <span v-bind="selectAttributes" - ><studip-icon shape="arr_1down" size="10" - /></span> - </template> - <template #no-options> - <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 : '']" - > - {{ 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" - /> - - <courseware-companion-overlay /> - </div> -</template> - -<script> -import { mapActions, mapGetters } from 'vuex'; -import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; -import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; -import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; -import StudipDialog from '../StudipDialog.vue'; - -export default { - name: 'courseware-content-overview-elements', - components: { - CoursewareCollapsibleBox, - CoursewareCompanionOverlay, - CoursewareCompanionBox, - StudipDialog - }, - data() { - return { - newElement: { - attributes: { - payload: {}, - }, - }, - newElementPurpose: 'content', - newElementTemplate: null, - uploadFileError: '', - } - }, - computed: { - ...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}); - }, - children() { - let view = this; - let children = []; - if(this.root?.relationships?.children?.data) { - this.root.relationships.children.data.forEach(function(child){ - let element = view.getElement({id: child.id}); - children.push(element); - }); - } - - 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') { - elements = elements.filter(element => { return element.attributes.purpose === this.purposeFilter}); - } - if (this.permissionFilter !== 'read') { - elements = elements.filter(element => { return element.attributes['can-edit'] }); - } - - return elements; - }, - colors() { - const colors = [ - { - name: this.$gettext('Schwarz'), - class: 'black', - hex: '#000000', - level: 100, - icon: 'black', - darkmode: true, - }, - { - name: this.$gettext('Weiß'), - class: 'white', - hex: '#ffffff', - level: 100, - icon: 'white', - darkmode: false, - }, - - { - name: this.$gettext('Blau'), - class: 'studip-blue', - hex: '#28497c', - level: 100, - icon: 'blue', - darkmode: true, - }, - { - name: this.$gettext('Hellblau'), - class: 'studip-lightblue', - hex: '#e7ebf1', - level: 40, - icon: 'lightblue', - darkmode: false, - }, - { - name: this.$gettext('Rot'), - class: 'studip-red', - hex: '#d60000', - level: 100, - icon: 'red', - darkmode: false, - }, - { - name: this.$gettext('Grün'), - class: 'studip-green', - hex: '#008512', - level: 100, - icon: 'green', - darkmode: true, - }, - { - name: this.$gettext('Gelb'), - class: 'studip-yellow', - hex: '#ffbd33', - level: 100, - icon: 'yellow', - darkmode: false, - }, - { - name: this.$gettext('Grau'), - class: 'studip-gray', - hex: '#636a71', - level: 100, - icon: 'grey', - darkmode: true, - }, - - { - name: this.$gettext('Holzkohle'), - class: 'charcoal', - hex: '#3c454e', - level: 100, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Königliches Purpur'), - class: 'royal-purple', - hex: '#8656a2', - level: 80, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Leguangrün'), - class: 'iguana-green', - hex: '#66b570', - level: 60, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Königin blau'), - class: 'queen-blue', - hex: '#536d96', - level: 80, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Helles Seegrün'), - class: 'verdigris', - hex: '#41afaa', - level: 80, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Maulbeere'), - class: 'mulberry', - hex: '#bf5796', - level: 80, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Kürbis'), - class: 'pumpkin', - hex: '#f26e00', - level: 100, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Sonnenschein'), - class: 'sunglow', - hex: '#ffca5c', - level: 80, - icon: false, - darkmode: false, - }, - { - name: this.$gettext('Apfelgrün'), - class: 'apple-green', - hex: '#8bbd40', - level: 80, - icon: false, - darkmode: true, - }, - ]; - let elementColors = []; - colors.forEach((color) => { - if (color.darkmode) { - elementColors.push(color); - } - }); - - return elementColors; - }, - selectableTemplates() { - return this.templates.filter(template => { - return template.attributes.purpose === this.newElementPurpose - }); - }, - currentTemplateStructure() { - if(this.newElementTemplate === null) { - return null; - } - - return JSON.parse(this.newElementTemplate.attributes.structure); - } - }, - methods: { - ...mapActions({ - createStructuralElement: 'createStructuralElement', - createStructuralElementWithTemplate: 'createStructuralElementWithTemplate', - loadElement: 'courseware-structural-elements/loadById', - setShowOverviewElementAddDialog: 'setShowOverviewElementAddDialog', - uploadImageForStructuralElement: 'uploadImageForStructuralElement', - companionInfo: 'companionInfo', - }), - getChildStyle(child) { - let url = child.relationships?.image?.meta?.['download-url']; - - if(url) { - return {'background-image': 'url(' + url + ')'}; - } else { - return {}; - } - }, - hasImage(child) { - return child.relationships?.image?.data !== null; - }, - 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); - }, - closeAddDialog() { - this.setShowOverviewElementAddDialog(false); - this.initNewElement(); - }, - async createElement() { - if (this.newElement.attributes.title == null ) { - this.companionInfo({ info: this.$gettext('Bitte geben Sie einen Titel für das Lernmaterial ein') }); - return false; - } - this.setShowOverviewElementAddDialog(false); - const file = this.$refs?.upload_image?.files[0]; - this.newElement.attributes.purpose = this.newElementPurpose; - await this.createStructuralElementWithTemplate({ - attributes: this.newElement.attributes, - templateId: this.newElementTemplate ? this.newElementTemplate.id : null, - parentId: this.root.id, - currentId: this.root.id, - }); - let newStructuralElement = this.$store.getters['courseware-structural-elements/lastCreated']; - - if (file) { - await this.uploadImageForStructuralElement({ - structuralElement: newStructuralElement, - file, - }).catch((error) => { - console.error(error); - this.companionInfo({ info: this.$gettext('Das Bild für das neue Lernmaterial konnte nicht gespeichert werden.') }); - }); - this.loadElement({id: newStructuralElement.id, options: {include: 'children'}}); - } - this.initNewElement(); - - }, - initNewElement() { - this.newElement = { - attributes: { - payload: {}, - purpose: '', - }, - template: '' - }; - }, - countChildren(element) { - let data = element.relationships.children.data; - if (data) { - return data.length; - } - return 0; - }, - checkUploadFile() { - const file = this.$refs?.upload_image?.files[0]; - if (file.size > 2097152) { - this.uploadFileError = this.$gettext('Diese Datei ist zu groß. Bitte wählen Sie eine Datei aus, die kleiner als 2MB groß ist.'); - } else if (!file.type.includes('image')) { - this.uploadFileError = this.$gettext('Diese Datei ist kein Bild. Bitte wählen Sie ein Bild aus.'); - } else { - this.uploadFileError = ''; - } - } - }, - watch: { - root(newRootObject) { - let view = this; - if (newRootObject) { - newRootObject.relationships.children.data.forEach(function(child) { - view.loadElement({id: child.id, options: {include: 'children'}}); - }); - } - }, - newElementPurpose() { - this.newElementTemplate = null; - } - } -} -</script> diff --git a/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue b/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue deleted file mode 100644 index 43fbd1e0c9cc87604cb7e1ccc56e17386c24ee68..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue +++ /dev/null @@ -1,99 +0,0 @@ -<template> - <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 } from 'vuex'; - -export default { - name: 'courseware-content-overview-filter-widget', - data() { - return { - permissionFilter: 'read', - purposeFilter: 'all', - sourceFilter: 'all', - }; - }, - methods: { - ...mapActions({ - 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> diff --git a/resources/vue/components/courseware/CoursewareCourseDashboard.vue b/resources/vue/components/courseware/CoursewareCourseDashboard.vue deleted file mode 100644 index 5f3b7ebed44739a417ddbb27af8cf0733be3d9d1..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/CoursewareCourseDashboard.vue +++ /dev/null @@ -1,93 +0,0 @@ -<template> - <div class="cw-dashboard-wrapper"> - <div v-if="defaultView" class="cw-dashboard cw-dashboard-default-view"> - <courseware-collapsible-box :title="$gettext('Überblick')" :open="true" class="cw-dashboard-box cw-dashboard-box-full"> - <div class="cw-dashboard-overview"> - <courseware-oblong :name="textChapterFinished" icon="accept" size="small"> - <template v-slot:oblongValue> {{ chapterCounter.finished }} </template> - </courseware-oblong> - <courseware-oblong :name="textChapterStarted" icon="play" size="small"> - <template v-slot:oblongValue> {{ chapterCounter.started }} </template> - </courseware-oblong> - <courseware-oblong :name="textChapterAhead" icon="timetable" size="small"> - <template v-slot:oblongValue> {{ chapterCounter.ahead }} </template> - </courseware-oblong> - </div> - </courseware-collapsible-box> - <courseware-collapsible-box :title="$gettext('Fortschritt')" :open="true" class="cw-dashboard-box cw-dashboard-box-half"> - <courseware-dashboard-progress /> - </courseware-collapsible-box> - <courseware-collapsible-box :title="$gettext('Aktivitäten')" :open="true" class="cw-dashboard-box cw-dashboard-box-half cw-content-loading"> - <courseware-dashboard-activities /> - </courseware-collapsible-box> - <courseware-collapsible-box :title="$gettext('Aufgaben')" :open="true" class="cw-dashboard-box cw-dashboard-box-full"> - <courseware-dashboard-tasks v-if="!userIsTeacher && teacherStatusLoaded"/> - <courseware-dashboard-students v-if="userIsTeacher && teacherStatusLoaded" /> - </courseware-collapsible-box> - </div> - <div v-if="taskView" class="cw-dashboard cw-dashboard-task-view"> - <courseware-dashboard-tasks v-if="!userIsTeacher && teacherStatusLoaded"/> - <courseware-dashboard-students v-if="userIsTeacher && teacherStatusLoaded" /> - </div> - <div v-if="activityView" class="cw-dashboard cw-dashboard-activity-view"> - <courseware-collapsible-box :title="$gettext('Aktivitäten')" :open="true" class="cw-dashboard-box cw-dashboard-box-full cw-content-loading"> - <courseware-dashboard-activities /> - </courseware-collapsible-box> - </div> - <courseware-companion-overlay /> - </div> -</template> - -<script> -import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; -import CoursewareDashboardProgress from './CoursewareDashboardProgress.vue'; -import CoursewareDashboardActivities from './CoursewareDashboardActivities.vue'; -import CoursewareDashboardTasks from './CoursewareDashboardTasks.vue' -import CoursewareDashboardStudents from './CoursewareDashboardStudents.vue' -import CoursewareOblong from './CoursewareOblong.vue'; -import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; -import { mapGetters } from 'vuex'; - -export default { - name: 'courseware-course-dashboard', - components: { - CoursewareCollapsibleBox, - CoursewareOblong, - CoursewareDashboardProgress, - CoursewareDashboardActivities, - CoursewareDashboardTasks, - CoursewareDashboardStudents, - CoursewareCompanionOverlay - }, - data() { - return { - textChapterAhead: this.$gettext('bevorstehende Seiten'), - textChapterStarted: this.$gettext('angefangene Seiten'), - textChapterFinished: this.$gettext('abgeschlossene Seiten'), - }; - }, - computed: { - ...mapGetters({ - dashboardViewMode: 'dashboardViewMode', - getCourseById: 'courses/byId', - getStructuralElementById: 'courseware-structural-elements/byId', - getUserById: 'users/byId', - teacherStatusLoaded: 'teacherStatusLoaded', - userId: 'userId', - userIsTeacher: 'userIsTeacher', - }), - chapterCounter() { - return STUDIP.courseware_chapter_counter; - }, - defaultView() { - return this.dashboardViewMode === 'default'; - }, - taskView() { - return this.dashboardViewMode === 'task'; - }, - activityView() { - return this.dashboardViewMode === 'activity'; - }, - } -}; -</script> diff --git a/resources/vue/components/courseware/CoursewareDashboardActivities.vue b/resources/vue/components/courseware/CoursewareDashboardActivities.vue deleted file mode 100644 index d068d3a8da7d9a5a9b2086434ec075e37302f138..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/CoursewareDashboardActivities.vue +++ /dev/null @@ -1,110 +0,0 @@ -<template> - <div class="cw-dashboard-activities-wrapper"> - <span v-if="loading"> - <div class="loading-indicator"> - <span class="load-1"></span> - <span class="load-2"></span> - <span class="load-3"></span> - </div> - </span> - <courseware-companion-box - v-if="activitiesList.length === 0 && !loading" - mood="sad" - :msgCompanion="$gettext('Es wurden keine Aktivitäten gefunden.')" - /> - <ul class="cw-dashboard-activities"> - <courseware-activity-item v-for="(item, index) in activitiesList" :key="index" :item="item" /> - </ul> - </div> -</template> - -<script> -import CoursewareActivityItem from './CoursewareActivityItem.vue'; -import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; -import { mapActions, mapGetters } from 'vuex'; - -export default { - name: 'courseware-dashboard-activities', - components: { - CoursewareActivityItem, - CoursewareCompanionBox, - }, - props: { - }, - data() { - return { - activitiesList: [], - loading: false - } - }, - computed: { - ...mapGetters({ - userId: 'userId', - getUserById: 'users/byId', - context: 'context', - getStructuralElementById: 'courseware-structural-elements/byId', - }), - }, - created: function () { - this.getActivities(); - }, - methods: { - ...mapActions([ - 'loadCoursewareActivities' - ]), - - async getActivities() { - this.loading = true; - let activities = await this.loadCoursewareActivities({ userId: this.userId, courseId: this.context.id}); - this.activitiesList = []; - - activities.forEach(activity => { - if(activity.type === 'activities') { - let username = this.getUserById({ id: activity.relationships.actor.data.id }).attributes['formatted-name']; - const date = new Date(activity.attributes.mkdate); - const activityStructuralElement = this.getStructuralElementById({ id: activity.relationships.object.meta["object-id"] }); - - let breadcrumb = activityStructuralElement.attributes.title; - let completeBreadcrumb = activityStructuralElement.attributes.title; - let currentStructuralElement = activityStructuralElement; - if (currentStructuralElement === undefined) { - return; - } - let i = 1; //max breadcrumb navigation depth check - while (currentStructuralElement.relationships.parent.data !== null) { - currentStructuralElement = this.getStructuralElementById({ id: currentStructuralElement.relationships.parent.data.id }); - if (currentStructuralElement === undefined) { - break; - } - completeBreadcrumb = currentStructuralElement.attributes.title + '/' + completeBreadcrumb; - - if(++i <= 3) { - breadcrumb = currentStructuralElement.attributes.title + '/' + breadcrumb; - - if(i == 3) { - breadcrumb = '.../' + breadcrumb; - } - } - } - let options = { year: 'numeric', month: '2-digit', day: '2-digit' }; - let data = { - username: username, - date: date.toLocaleString('de-DE', options), - type: activity.attributes.verb, - text: activity.attributes.title, - complete_breadcrumb: completeBreadcrumb, - element_breadcrumb: breadcrumb, - element_id: activity.relationships.object.meta["object-id"], - context_id: activity.relationships.context.data.id, - content: activity.attributes.content - } - - this.activitiesList.push(data); - } - }); - - this.loading = false; - } - } -}; -</script> diff --git a/resources/vue/components/courseware/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/CoursewareDashboardStudents.vue index d0a15ce9197d4715c4aa3cb1d23d183564071267..6ad27bfa25d22127c5ac5657e046f59493f6da42 100644 --- a/resources/vue/components/courseware/CoursewareDashboardStudents.vue +++ b/resources/vue/components/courseware/CoursewareDashboardStudents.vue @@ -46,7 +46,7 @@ </a> <span v-else>{{ element.attributes.title }}</span> </td> - <td>{{ task.attributes.progress.toFixed(2) }}%</td> + <td>{{ task.attributes?.progress?.toFixed(2) || '-.--' }}%</td> <td>{{ getReadableDate(task.attributes['submission-date']) }}</td> <td> <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" /> @@ -110,14 +110,8 @@ <div v-else> <courseware-companion-box mood="pointing" - :msgCompanion=" - $gettext('Es wurden bisher keine Aufgaben gestellt.') + '<br>' + - $gettext('Wenn Sie eine Aufgabe stellen möchten, nutzen Sie bitte in der Verwaltung der Courseware die Funktion "Aufgabe verteilen".') - " + :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')" > - <template v-slot:companionActions> - <a class="button" :href="managerUrl"><translate>Zur Verwaltung</translate></a> - </template> </courseware-companion-box> </div> <studip-dialog @@ -206,6 +200,7 @@ </form> </template> </studip-dialog> + <courseware-tasks-dialog-distribute v-if="showTasksDistributeDialog"/> </div> </template> @@ -214,9 +209,11 @@ import StudipIcon from './../StudipIcon.vue'; import StudipDialog from './../StudipDialog.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareDateInput from './CoursewareDateInput.vue'; +import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; import taskHelperMixin from '../../mixins/courseware/task-helper.js'; import { mapActions, mapGetters } from 'vuex'; + export default { name: 'courseware-dashboard-students', mixins: [taskHelperMixin], @@ -225,6 +222,7 @@ export default { CoursewareDateInput, StudipIcon, StudipDialog, + CoursewareTasksDialogDistribute, }, data() { return { @@ -261,6 +259,7 @@ export default { getElementById: 'courseware-structural-elements/byId', getFeedbackById: 'courseware-task-feedback/byId', relatedTaskGroups: 'courseware-task-groups/related', + showTasksDistributeDialog: 'showTasksDistributeDialog' }), tasks() { return this.allTasks.map((task) => { diff --git a/resources/vue/components/courseware/CoursewareDashboardViewWidget.vue b/resources/vue/components/courseware/CoursewareDashboardViewWidget.vue deleted file mode 100644 index e0848271eaa3f719981674a461435eb465fbb328..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/CoursewareDashboardViewWidget.vue +++ /dev/null @@ -1,56 +0,0 @@ -<template> - <ul class="widget-list widget-links sidebar-views cw-view-widget"> - <li :class="{ active: defaultView }"> - <a href="#" @click.prevent="setDefaultView"> - <translate>Standard</translate> - </a> - </li> - <li :class="{ active: taskView }"> - <a href="#" @click.prevent="setTaskView"> - <translate>Aufgaben</translate> - </a> - </li> - <li :class="{ active: activityView }"> - <a href="#" @click.prevent="setActivityView"> - <translate>Aktivitäten</translate> - </a> - </li> - </ul> -</template> - -<script> -import { mapActions, mapGetters } from 'vuex'; - -export default { - name: 'courseware-dashboard-view-widget', - computed: { - ...mapGetters({ - dashboardViewMode: 'dashboardViewMode', - context: 'context', - }), - defaultView() { - return this.dashboardViewMode === 'default'; - }, - taskView() { - return this.dashboardViewMode === 'task'; - }, - activityView() { - return this.dashboardViewMode === 'activity'; - }, - }, - methods: { - ...mapActions({ - setDashboardViewMode: 'setDashboardViewMode' - }), - setDefaultView() { - this.setDashboardViewMode('default'); - }, - setTaskView() { - this.setDashboardViewMode('task'); - }, - setActivityView() { - this.setDashboardViewMode('activity'); - }, - }, -}; -</script> diff --git a/resources/vue/components/courseware/CoursewareDefaultContainer.vue b/resources/vue/components/courseware/CoursewareDefaultContainer.vue index b1eef065d4744bccc8dfdf29388679a67bf48c4e..fd89439b3558d378c2548695dd8a7e7772f025c1 100644 --- a/resources/vue/components/courseware/CoursewareDefaultContainer.vue +++ b/resources/vue/components/courseware/CoursewareDefaultContainer.vue @@ -105,6 +105,7 @@ export default { }, computed: { ...mapGetters({ + blockAdder: 'blockAdder', userId: 'userId', userById: 'users/byId', viewMode: 'viewMode' @@ -146,6 +147,7 @@ export default { deleteContainer: 'deleteContainer', lockObject: 'lockObject', unlockObject: 'unlockObject', + coursewareBlockAdder: 'coursewareBlockAdder' }), async displayEditDialog() { await this.loadContainer({ id: this.container.id, options: { include: 'edit-blocker' } }); @@ -227,8 +229,8 @@ export default { containerId: this.container.id, structuralElementId: this.container.relationships['structural-element'].data.id, }); - if(Object.keys(this.$store.getters.blockAdder).length !== 0 && this.$store.getters.blockAdder.container.id === this.container.id) { - this.$store.dispatch('coursewareBlockAdder', {}); + if(Object.keys(this.blockAdder).length !== 0 && this.blockAdder.container.id === this.container.id) { + this.coursewareBlockAdder({}); } this.showDeleteDialog = false; }, diff --git a/resources/vue/components/courseware/CoursewareDownloadBlock.vue b/resources/vue/components/courseware/CoursewareDownloadBlock.vue index d5c08d2eb35b34c00bd35c39a8e63f0f19d543a5..403de4b0f9bd0e250b6aa3e361b0017300242f26 100644 --- a/resources/vue/components/courseware/CoursewareDownloadBlock.vue +++ b/resources/vue/components/courseware/CoursewareDownloadBlock.vue @@ -148,6 +148,7 @@ export default { ...mapActions({ loadFileRef: 'file-refs/loadById', updateBlock: 'updateBlockInContainer', + updateUserDataFields: 'courseware-user-data-fields/update' }), initCurrentData() { this.currentTitle = this.title; @@ -261,7 +262,7 @@ export default { } } }; - this.$store.dispatch('courseware-user-data-fields/update', data); + this.updateUserDataFields(data); this.userProgress = 1; }, }, diff --git a/resources/vue/components/courseware/CoursewareEmptyElementBox.vue b/resources/vue/components/courseware/CoursewareEmptyElementBox.vue index b64413d26c8b7f5db556ab0874622021bd28dd38..4934e05b87507753d06bf955ea0710df5aa4d0c1 100644 --- a/resources/vue/components/courseware/CoursewareEmptyElementBox.vue +++ b/resources/vue/components/courseware/CoursewareEmptyElementBox.vue @@ -1,5 +1,5 @@ <template> - <div class="cw-wellcome-screen"> + <div class="cw-welcome-screen"> <courseware-companion-box :msgCompanion="this.$gettext('Es wurden bisher noch keine Inhalte eingepflegt.')"> <template v-slot:companionActions> <button v-if="canEdit && noContainers" class="button" @click="addContainer"><translate>Einen Abschnitt hinzufügen</translate></button> @@ -10,7 +10,7 @@ </template> <script> -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; export default { @@ -34,16 +34,23 @@ export default { } }, methods: { + ...mapActions({ + coursewareViewMode: 'coursewareViewMode', + coursewareConsumeMode: 'coursewareConsumeMode', + coursewareContainerAdder: 'coursewareContainerAdder', + coursewareSelectedToolbarItem: 'coursewareSelectedToolbarItem', + coursewareShowToolbar: 'coursewareShowToolbar' + }), addContainer() { - this.$store.dispatch('coursewareViewMode', 'edit'); - this.$store.dispatch('coursewareConsumeMode', false); - this.$store.dispatch('coursewareContainerAdder', true); - this.$store.dispatch('coursewareSelectedToolbarItem', 'blockadder'); - this.$store.dispatch('coursewareShowToolbar', true); + this.coursewareViewMode('edit'); + this.coursewareConsumeMode(false); + this.coursewareContainerAdder(true); + this.coursewareSelectedToolbarItem('blockadder'); + this.coursewareShowToolbar(true); }, switchToEditView() { - this.$store.dispatch('coursewareViewMode', 'edit'); - this.$store.dispatch('coursewareConsumeMode', false); + this.coursewareViewMode('edit'); + this.coursewareConsumeMode(false); } } diff --git a/resources/vue/components/courseware/CoursewareExportWidget.vue b/resources/vue/components/courseware/CoursewareExportWidget.vue index 6dea7158198dc72f15e88e9dc67412948987a658..2b4b6255bf3cbbb61e66e23fc59158ef6110d29d 100644 --- a/resources/vue/components/courseware/CoursewareExportWidget.vue +++ b/resources/vue/components/courseware/CoursewareExportWidget.vue @@ -4,21 +4,21 @@ <ul class="widget-list widget-links cw-export-widget" v-if="structuralElement"> <li v-if="showExportArchiv" class="cw-export-widget-export"> <button @click="exportElement"> - <translate>Seite exportieren</translate> + {{ $gettext('Lerninhalte exportieren') }} </button> </li> <li v-if="showExportPdf" class="cw-export-widget-export-pdf"> <button @click="pdfElement"> - <translate>Seite als pdf-Dokument exportieren</translate> + {{ $gettext('PDF-Dokument erstellen') }} </button> </li> <li v-if="showOer" class="cw-export-widget-oer"> <button @click="oerElement"> - <translate>Seite auf dem OER Campus veröffentlichen</translate> + {{ $gettext('Auf OER Campus veröffentlichen') }} </button> </li> <li v-if="!showExportArchiv && !showExportPdf && !showOer"> - <translate>Keine Exportoptionen verfügbar</translate> + {{ $gettext('Keine Exportoptionen verfügbar') }} </li> </ul> </template> diff --git a/resources/vue/components/courseware/CoursewareHeadlineBlock.vue b/resources/vue/components/courseware/CoursewareHeadlineBlock.vue index 98bdc9cb4f333fb8b8ce3a34bdad3df78235c990..21179bf0aae408283c27f296283d8a7d0073d9bc 100644 --- a/resources/vue/components/courseware/CoursewareHeadlineBlock.vue +++ b/resources/vue/components/courseware/CoursewareHeadlineBlock.vue @@ -168,12 +168,13 @@ import CoursewareDefaultBlock from './CoursewareDefaultBlock.vue'; import CoursewareFileChooser from './CoursewareFileChooser.vue'; import { blockMixin } from './block-mixin.js'; +import colorMixin from '@/vue/mixins/courseware/colors.js'; import { mapGetters, mapActions } from 'vuex'; import contentIcons from './content-icons.js'; export default { name: 'courseware-headline-block', - mixins: [blockMixin], + mixins: [blockMixin, colorMixin], components: { CoursewareDefaultBlock, CoursewareFileChooser, @@ -245,41 +246,10 @@ export default { return contentIcons; }, colors() { - const colors = [ - {name: this.$gettext('Schwarz'), class: 'black', hex: '#000000', level: 100, icon: 'black', darkmode: true}, - {name: this.$gettext('Weiß'), class: 'white', hex: '#ffffff', level: 100, icon: 'white', darkmode: false}, - - {name: this.$gettext('Blau'), class: 'studip-blue', hex: '#28497c', level: 100, icon: 'blue', darkmode: true}, - {name: this.$gettext('Hellblau'), class: 'studip-lightblue', hex: '#e7ebf1', level: 40, icon: 'lightblue', darkmode: false}, - {name: this.$gettext('Rot'), class: 'studip-red', hex: '#d60000', level: 100, icon: 'red', darkmode: false}, - {name: this.$gettext('Grün'), class: 'studip-green', hex: '#008512', level: 100, icon: 'green', darkmode: true}, - {name: this.$gettext('Gelb'), class: 'studip-yellow', hex: '#ffbd33', level: 100, icon: 'yellow', darkmode: false}, - {name: this.$gettext('Grau'), class: 'studip-gray', hex: '#636a71', level: 100, icon: 'grey', darkmode: true}, - - {name: this.$gettext('Holzkohle'), class: 'charcoal', hex: '#3c454e', level: 100, icon: false, darkmode: true}, - {name: this.$gettext('Königliches Purpur'), class: 'royal-purple', hex: '#8656a2', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Leguangrün'), class: 'iguana-green', hex: '#66b570', level: 60, icon: false, darkmode: true}, - {name: this.$gettext('Königin blau'), class: 'queen-blue', hex: '#536d96', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Helles Seegrün'), class: 'verdigris', hex: '#41afaa', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Maulbeere'), class: 'mulberry', hex: '#bf5796', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Kürbis'), class: 'pumpkin', hex: '#f26e00', level: 100, icon: false, darkmode: true}, - {name: this.$gettext('Sonnenschein'), class: 'sunglow', hex: '#ffca5c', level: 80, icon: false, darkmode: false}, - {name: this.$gettext('Apfelgrün'), class: 'apple-green', hex: '#8bbd40', level: 80, icon: false, darkmode: true}, - ]; - - return colors; + return this.mixinColors; }, iconColors() { - const iconColors = [ - {name: this.$gettext('Schwarz'), class: 'black', hex: '#000000'}, - {name: this.$gettext('Weiß'), class: 'white', hex: '#ffffff'}, - {name: this.$gettext('Blau'), class: 'studip-blue', hex: '#28497c'}, - {name: this.$gettext('Rot'), class: 'studip-red', hex: '#d60000'}, - {name: this.$gettext('Grün'), class: 'studip-green', hex: '#008512'}, - {name: this.$gettext('Gelb'), class: 'studip-yellow', hex: '#ffbd33'}, - ]; - - return iconColors; + return this.mixinColors.filter(color => color.icon && color.class !== 'studip-lightblue'); }, textStyle() { let style = {}; diff --git a/resources/vue/components/courseware/CoursewareImportWidget.vue b/resources/vue/components/courseware/CoursewareImportWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..d2ed4448dcb8e370f0003a3669e0553b34494bfa --- /dev/null +++ b/resources/vue/components/courseware/CoursewareImportWidget.vue @@ -0,0 +1,42 @@ +<template> + <sidebar-widget :title="$gettext('Import')"> + <template #content> + <ul class="widget-list widget-links cw-import-widget"> + <li class="cw-import-widget-archive"> + <button @click="importElements"> + {{ $gettext('Lerninhalte importieren') }} + </button> + </li> + <li class="cw-import-widget-copy"> + <button @click="copyElements"> + {{ $gettext('Lerninhalte kopieren') }} + </button> + </li> + </ul> + </template> + </sidebar-widget> +</template> + +<script> +import SidebarWidget from '../SidebarWidget.vue'; +import { mapActions } from 'vuex'; + +export default { + name: 'courseware-import-widget', + components: { + SidebarWidget, + }, + methods: { + ...mapActions({ + showElementImportDialog: 'showElementImportDialog', + showElementCopyDialog: 'showElementCopyDialog' + }), + importElements() { + this.showElementImportDialog(true); + }, + copyElements() { + this.showElementCopyDialog(true); + }, + }, +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareKeyPointBlock.vue b/resources/vue/components/courseware/CoursewareKeyPointBlock.vue index 2fc29b4593570f7f80bbeea65f2c8d8031be439f..b495431d6c95c2cfb267a86855f029cd2c8b1708 100644 --- a/resources/vue/components/courseware/CoursewareKeyPointBlock.vue +++ b/resources/vue/components/courseware/CoursewareKeyPointBlock.vue @@ -80,13 +80,14 @@ <script> import CoursewareDefaultBlock from './CoursewareDefaultBlock.vue'; +import colorMixin from '@/vue/mixins/courseware/colors.js'; import { blockMixin } from './block-mixin.js'; import { mapActions } from 'vuex'; import contentIcons from './content-icons.js'; export default { name: 'courseware-key-point-block', - mixins: [blockMixin], + mixins: [blockMixin, colorMixin], components: { CoursewareDefaultBlock, }, @@ -110,36 +111,9 @@ export default { return contentIcons; }, colors() { - const colors = [ - {name: this.$gettext('Schwarz'), class: 'black', hex: '#000000', level: 100, icon: 'black', darkmode: true}, - {name: this.$gettext('Weiß'), class: 'white', hex: '#ffffff', level: 100, icon: 'white', darkmode: false}, - - {name: this.$gettext('Blau'), class: 'studip-blue', hex: '#28497c', level: 100, icon: 'blue', darkmode: true}, - {name: this.$gettext('Hellblau'), class: 'studip-lightblue', hex: '#e7ebf1', level: 40, icon: 'lightblue', darkmode: false}, - {name: this.$gettext('Rot'), class: 'studip-red', hex: '#d60000', level: 100, icon: 'red', darkmode: false}, - {name: this.$gettext('Grün'), class: 'studip-green', hex: '#008512', level: 100, icon: 'green', darkmode: true}, - {name: this.$gettext('Gelb'), class: 'studip-yellow', hex: '#ffbd33', level: 100, icon: 'yellow', darkmode: false}, - {name: this.$gettext('Grau'), class: 'studip-gray', hex: '#636a71', level: 100, icon: 'grey', darkmode: true}, - - {name: this.$gettext('Holzkohle'), class: 'charcoal', hex: '#3c454e', level: 100, icon: false, darkmode: true}, - {name: this.$gettext('Königliches Purpur'), class: 'royal-purple', hex: '#8656a2', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Leguangrün'), class: 'iguana-green', hex: '#66b570', level: 60, icon: false, darkmode: true}, - {name: this.$gettext('Königin blau'), class: 'queen-blue', hex: '#536d96', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Helles Seegrün'), class: 'verdigris', hex: '#41afaa', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Maulbeere'), class: 'mulberry', hex: '#bf5796', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Kürbis'), class: 'pumpkin', hex: '#f26e00', level: 100, icon: false, darkmode: true}, - {name: this.$gettext('Sonnenschein'), class: 'sunglow', hex: '#ffca5c', level: 80, icon: false, darkmode: false}, - {name: this.$gettext('Apfelgrün'), class: 'apple-green', hex: '#8bbd40', level: 80, icon: false, darkmode: true}, - ]; - let iconColors = []; - - colors.forEach(color => { - if(color.icon && color.class !== 'white' && color.class !== 'studip-lightblue') { - iconColors.push(color); - } - }); - - return iconColors; + return this.mixinColors.filter(color => + color.icon && color.class !== 'white' && color.class !== 'studip-lightblue' + ); }, text() { return this.block?.attributes?.payload?.text; diff --git a/resources/vue/components/courseware/CoursewareManagerElement.vue b/resources/vue/components/courseware/CoursewareManagerElement.vue index 1de92270651a3fecaa56559e05b5b068128d1f2f..232d21c053e52b341d07e6a89498acf433e1d73c 100644 --- a/resources/vue/components/courseware/CoursewareManagerElement.vue +++ b/resources/vue/components/courseware/CoursewareManagerElement.vue @@ -177,6 +177,7 @@ export default { ...mapGetters({ childrenById: 'courseware-structure/children', containerById: 'courseware-containers/byId', + filingData: 'filingData', structuralElementById: 'courseware-structural-elements/byId', }), isCurrent() { @@ -308,9 +309,6 @@ export default { .map((id) => this.structuralElementById({ id })) .filter(Boolean); }, - filingData() { - return this.$store.getters.filingData; - }, copyProcessFailedMessage() { let message = this.$gettext('Der Kopiervorgang ist fehlgeschlagen.'); if (this.text.copyProcessFailed.length) { diff --git a/resources/vue/components/courseware/CoursewareManagerFiling.vue b/resources/vue/components/courseware/CoursewareManagerFiling.vue index fa0a7133837ec813bfad3147f8e55d6e81bab73e..71aeb612e1cfccf4f49c30b4ba54b18f9db919c6 100644 --- a/resources/vue/components/courseware/CoursewareManagerFiling.vue +++ b/resources/vue/components/courseware/CoursewareManagerFiling.vue @@ -12,6 +12,8 @@ </template> <script> +import { mapActions, mapGetters } from 'vuex'; + export default { name: 'courseware-manager-filing', props: { @@ -27,19 +29,22 @@ export default { }; }, computed: { - filingData() { - return this.$store.getters.filingData; - }, + ...mapGetters({ + filingData: 'filingData', + }), }, methods: { + ...mapActions({ + cwManagerFilingData: 'cwManagerFilingData' + }), toggleFiling() { if (this.disabled) { return false; } if (this.active) { - this.$store.dispatch('cwManagerFilingData', {}); + this.cwManagerFilingData({}); } else { - this.$store.dispatch('cwManagerFilingData', { parentId: this.parentId, itemType: this.itemType, parentItem: this.parentItem }); + this.cwManagerFilingData({ parentId: this.parentId, itemType: this.itemType, parentItem: this.parentItem }); } }, }, diff --git a/resources/vue/components/courseware/CoursewareRibbon.vue b/resources/vue/components/courseware/CoursewareRibbon.vue index bcb44c64bcdabafdcd425df9259b6e4c07d36e91..1937c9c73b10772de6a8d36ee14e3a998c1b3fa7 100644 --- a/resources/vue/components/courseware/CoursewareRibbon.vue +++ b/resources/vue/components/courseware/CoursewareRibbon.vue @@ -51,6 +51,7 @@ <script> import CoursewareRibbonToolbar from './CoursewareRibbonToolbar.vue'; +import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-ribbon', @@ -87,12 +88,10 @@ export default { }; }, computed: { - consumeMode() { - return this.$store.getters.consumeMode; - }, - toolsActive() { - return this.$store.getters.showToolbar; - }, + ...mapGetters({ + consumeMode: 'consumeMode', + toolsActive: 'showToolbar' + }), breadcrumbFallback() { return window.outerWidth < 1200; }, @@ -105,21 +104,28 @@ export default { } }, methods: { + ...mapActions({ + coursewareConsumeMode: 'coursewareConsumeMode', + coursewareSelectedToolbarItem: 'coursewareSelectedToolbarItem', + coursewareViewMode: 'coursewareViewMode', + coursewareShowToolbar: 'coursewareShowToolbar' + + }), toggleConsumeMode() { STUDIP.Vue.emit('toggle-focus-mode', !this.consumeMode); if (!this.consumeMode) { - this.$store.dispatch('coursewareConsumeMode', true); - this.$store.dispatch('coursewareSelectedToolbarItem', 'contents'); - this.$store.dispatch('coursewareViewMode', 'read'); + this.coursewareConsumeMode(true); + this.coursewareSelectedToolbarItem('contents'); + this.coursewareViewMode('read'); } else { - this.$store.dispatch('coursewareConsumeMode', false); + this.coursewareConsumeMode(false); } }, activeToolbar() { - this.$store.dispatch('coursewareShowToolbar', true); + this.coursewareShowToolbar(true); }, deactivateToolbar() { - this.$store.dispatch('coursewareShowToolbar', false); + this.coursewareShowToolbar(false); }, handleScroll() { if (window.outerWidth > 767) { diff --git a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue index d90616c78f1e2aa9ba298d31be8f88af05b06d9a..e1373ba012d70fa2bcc3db7b7f74c5c292c74808 100644 --- a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue +++ b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue @@ -36,17 +36,6 @@ @blockAdded="$emit('blockAdded')" /> </courseware-tab> - <courseware-tab - v-if="displaySettings" - :name="$gettext('Einstellungen')" - :selected="showAdmin" - alias="admin" - :index="2" - > - <courseware-tools-admin - id="cw-ribbon-tool-admin" - /> - </courseware-tab> </courseware-tabs> <button :title="$gettext('schließen')" @@ -62,7 +51,6 @@ <script> import CoursewareTabs from './CoursewareTabs.vue'; import CoursewareTab from './CoursewareTab.vue'; -import CoursewareToolsAdmin from './CoursewareToolsAdmin.vue'; import CoursewareToolsBlockadder from './CoursewareToolsBlockadder.vue'; import CoursewareToolsContents from './CoursewareToolsContents.vue'; import { FocusTrap } from 'focus-trap-vue'; @@ -73,7 +61,6 @@ export default { components: { CoursewareTabs, CoursewareTab, - CoursewareToolsAdmin, CoursewareToolsBlockadder, CoursewareToolsContents, FocusTrap, @@ -97,7 +84,6 @@ export default { data() { return { showContents: true, - showAdmin: false, showBlockAdder: false, trap: false, initialFocusElement: null @@ -143,11 +129,11 @@ export default { }, methods: { ...mapActions({ - setToolbarItem: 'coursewareSelectedToolbarItem' + setToolbarItem: 'coursewareSelectedToolbarItem', + coursewareContainerAdder: 'coursewareContainerAdder' }), selectTool(alias) { this.showContents = false; - this.showAdmin = false; this.showBlockAdder = false; switch (alias) { @@ -156,10 +142,6 @@ export default { this.disableContainerAdder(); this.scrollToCurrent(); break; - case 'admin': - this.showAdmin = true; - this.disableContainerAdder(); - break; case 'blockadder': this.showBlockAdder = true; break; @@ -171,7 +153,7 @@ export default { }, disableContainerAdder() { if (this.containerAdder !== false) { - this.$store.dispatch('coursewareContainerAdder', false); + this.coursewareContainerAdder(false); } }, scrollToCurrent() { diff --git a/resources/vue/components/courseware/CoursewareSearchWidget.vue b/resources/vue/components/courseware/CoursewareSearchWidget.vue index 95f71f4d5f6159df547d0d09d76601f266ab00e8..255c7998080e0f8d8d12aa3e0ce129700b3feef3 100644 --- a/resources/vue/components/courseware/CoursewareSearchWidget.vue +++ b/resources/vue/components/courseware/CoursewareSearchWidget.vue @@ -1,40 +1,49 @@ <template> - <form class="sidebar-search" @submit.prevent=""> - <ul class="needles"> - <li> - <div class="input-group files-search"> - <input - type="text" - v-model="searchTerm" - :aria-label="$gettext('Geben Sie einen Suchbegriff mit mindestens 3 Zeichen ein.')" - /> - <button v-if="searched" @click.prevent="setShowSearchResults(false)" - class="reset-search" :title="$gettext('Suche zurücksetzen')"> - <studip-icon shape="decline" size="20"></studip-icon> - </button> - <button - type="submit" - :value="$gettext('Suchen')" - aria-controls="search" - class="submit-search" - @click="loadResults" - > - <studip-icon shape="search" size="20"></studip-icon> - </button> - </div> - </li> - </ul> - </form> + <sidebar-widget :title="$gettext('Suche')"> + <template #content> + <form class="sidebar-search" @submit.prevent=""> + <ul class="needles"> + <li> + <div class="input-group files-search"> + <input + type="text" + v-model="searchTerm" + :aria-label="$gettext('Geben Sie einen Suchbegriff mit mindestens 3 Zeichen ein.')" + /> + <a v-if="showSearchResults" @click.prevent="setShowSearchResults(false)" + class="reset-search"> + <studip-icon shape="decline" size="20"></studip-icon> + </a> + <button + type="submit" + :value="$gettext('Suchen')" + aria-controls="search" + class="submit-search" + @click="loadResults" + > + <studip-icon shape="search" size="20"></studip-icon> + </button> + </div> + </li> + </ul> + </form> + </template> + </sidebar-widget> </template> <script> -import axios from 'axios'; -import { mapActions, mapGetters } from 'vuex'; +import SidebarWidget from '../SidebarWidget.vue'; import StudipIcon from '../StudipIcon.vue'; +import { mapActions, mapGetters } from 'vuex'; +import axios from 'axios'; + export default { name: 'courseware-search-widget', - components: { StudipIcon }, + components: { + StudipIcon, + SidebarWidget, + }, data() { return { searchTerm: '' @@ -44,16 +53,15 @@ export default { ...mapGetters({ courseware: 'courseware', context: 'context', + showSearchResults: 'showSearchResults' }), - searched() { - return this.$store.state.courseware.showSearchResults - } }, methods: { ...mapActions({ setShowSearchResults: 'setShowSearchResults', setSearchResults: 'setSearchResults', - companionWarning: 'companionWarning' + companionWarning: 'companionWarning', + companionError: 'companionError' }), loadResults() { if (this.searchTerm.length < 3) { @@ -78,11 +86,9 @@ export default { this.setSearchResults([]); } }).catch(error => { - console.debug(error); + this.companionError({ info: this.$gettext('Bei der Anfrage ist ein Fehler aufgetreten.')}); }); } } - - } </script> diff --git a/resources/vue/components/courseware/CoursewareShelfActionWidget.vue b/resources/vue/components/courseware/CoursewareShelfActionWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..44282b16d90c4808f23d9945c0e22ebcbb7272fb --- /dev/null +++ b/resources/vue/components/courseware/CoursewareShelfActionWidget.vue @@ -0,0 +1,30 @@ +<template> + <sidebar-widget :title="$gettext('Aktionen')"> + <template #content> + <ul class="widget-list widget-links cw-action-widget"> + <li class="cw-action-widget-add"> + <button @click="setShowUnitAddDialog(true)"> + {{ $gettext('Lernmaterial hinzufügen') }} + </button> + </li> + </ul> + </template> + </sidebar-widget> +</template> + +<script> +import SidebarWidget from '../SidebarWidget.vue'; +import { mapActions } from 'vuex'; + +export default { + name: 'courseware-shelf-action-widget', + components: { + SidebarWidget + }, + methods: { + ...mapActions({ + setShowUnitAddDialog: 'setShowUnitAddDialog', + }), + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue b/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue new file mode 100644 index 0000000000000000000000000000000000000000..80f23a3f9d7a8056d86c32ccfb43f107c6937b10 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue @@ -0,0 +1,277 @@ +<template> + <studip-wizard-dialog + :title="$gettext('Lernmaterial hinzufügen')" + :confirmText="$gettext('Erstellen')" + :closeText="$gettext('Abbrechen')" + :slots="wizardSlots" + :lastRequiredSlotId="1" + :requirements="requirements" + @close="setShowUnitAddDialog(false)" + @confirm="createUnit" + > + <template v-slot:basic> + <form class="default" @submit.prevent=""> + <label> + <span>{{ text.title }}</span><span aria-hidden="true" class="wizard-required">*</span> + <input type="text" v-model="addWizardData.title" required/> + </label> + <label> + <span>{{ text.description }}</span><span aria-hidden="true" class="wizard-required">*</span> + <textarea v-model="addWizardData.description" required/> + </label> + </form> + </template> + <template v-slot:layout> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Bild') }} + <br> + <input class="cw-file-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> + {{ $gettext('Farbe') }} + <studip-select + v-model="addWizardData.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> + {{ $gettext('Es steht keine Auswahl zur Verfügung.') }} + </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> + </template> + <template v-slot:advanced> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Art des Lernmaterials') }} + <select v-model="addWizardData.purpose"> + <option value="content">{{ $gettext('Inhalt') }}</option> + <option value="oer">{{ $gettext('OER-Material') }}</option> + <option value="portfolio">{{ $gettext('ePortfolio') }}</option> + <option value="draft">{{ $gettext('Entwurf') }}</option> + <option v-if="!inCourseContext" value="template">{{ $gettext('Aufgabenvorlage') }}</option> + <option value="other">{{ $gettext('Sonstiges') }}</option> + </select> + </label> + <label> + {{ $gettext('Lizenztyp') }} + <select v-model="addWizardData.license_type"> + <option v-for="license in licenses" :key="license.id" :value="license.id"> + {{ license.name }} + </option> + </select> + </label> + <label> + {{ $gettext('Geschätzter zeitlicher Aufwand') }} + <input type="text" v-model="addWizardData.required_time" /> + </label> + <label> + {{ $gettext('Niveau') }}<br /> + {{ $gettext('von') }} + <select v-model="addWizardData.difficulty_start"> + <option + v-for="difficulty_start in 12" + :key="difficulty_start" + :value="difficulty_start" + > + {{ difficulty_start }} + </option> + </select> + {{ $gettext('bis') }} + <select v-model="addWizardData.difficulty_end"> + <option + v-for="difficulty_end in 12" + :key="difficulty_end" + :value="difficulty_end" + > + {{ difficulty_end }} + </option> + </select> + </label> + </form> + </template> + </studip-wizard-dialog> +</template> + +<script> +import StudipSelect from './../StudipSelect.vue'; +import StudipWizardDialog from './../StudipWizardDialog.vue'; +import colorMixin from '@/vue/mixins/courseware/colors.js'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-shelf-dialog-add', + mixins: [colorMixin], + components: { + StudipWizardDialog, + StudipSelect, + }, + data() { + return { + wizardSlots: [ + { id: 1, valid: false, name: 'basic', title: this.$gettext('Grundeinstellungen'), icon: 'courseware', + description: this.$gettext('Wählen Sie einen kurzen, prägnanten Titel und beschreiben Sie in einigen Worten den Inhalt des Lernmaterials. Eine Beschreibung erleichtert Lernenden die Auswahl des Lernmaterials.') }, + { id: 2, valid: true, name: 'layout', title: this.$gettext('Erscheinung'), icon: 'picture', + description: this.$gettext('Ein Vorschaubild motiviert Lernende das Lernmaterial zu erkunden. Die Kombination aus Bild und Farbe erleichtert das wiederfinden des Lernmaterials in der Übersicht.') }, + { id: 3, valid: true, name: 'advanced', title: this.$gettext('Zusatzangaben'), icon: 'doctoral_cap', + description: this.$gettext('Hier können Sie detaillierte Angaben zum Lernmaterial eintragen. Diese sind besonders interessant wenn das Lernmaterial als OER geteilt wird.') } + ], + text: { + title: this.$gettext('Titel des Lernmaterials'), + description: this.$gettext('Beschreibung') + }, + addWizardData: {}, + uploadFileError: '', + requirements: [] + } + }, + computed: { + ...mapGetters({ + licenses: 'licenses', + context: 'context', + lastCreateCoursewareUnit: 'courseware-units/lastCreated', + structuralElementById: 'courseware-structural-elements/byId', + }), + inCourseContext() { + return this.context.type === 'courses'; + }, + colors() { + return this.mixinColors.filter(color => color.darkmode); + } + }, + mounted() { + this.initAddWizardData(); + }, + methods: { + ...mapActions({ + companionError: 'companionError', + companionInfo: 'companionInfo', + companionSuccess: 'companionSuccess', + createCoursewareUnit: 'courseware-units/create', + setShowUnitAddDialog: 'setShowUnitAddDialog', + loadStructuralElementById: 'courseware-structural-elements/loadById', + uploadImageForStructuralElement: 'uploadImageForStructuralElement', + }), + initAddWizardData() { + this.addWizardData = { + title: '', + description: '', + purpose: 'content', + color: 'studip-blue', + } + }, + validateSlots() { + let valid = true; + this.wizardSlots.forEach(slot => { + if (!slot.valid) { + valid = false; + } + }); + + return valid; + }, + checkUploadFile() { + const file = this.$refs?.upload_image?.files[0]; + if (file.size > 2097152) { + this.uploadFileError = this.$gettext('Diese Datei ist zu groß. Bitte wählen Sie eine Datei aus, die kleiner als 2MB ist.'); + } else if (!file.type.includes('image')) { + this.uploadFileError = this.$gettext('Diese Datei ist kein Bild. Bitte wählen Sie ein Bild aus.'); + } else { + this.uploadFileError = ''; + } + }, + async createUnit() { + if (!this.validateSlots()) { + this.companionError({ + info: this.$gettext('Bitte füllen Sie alle notwendigen Angaben aus.'), + }); + return false; + } + const file = this.$refs?.upload_image?.files[0]; + const unit = { + attributes: { + title: this.addWizardData.title, + purpose: this.addWizardData.purpose, + payload: { + description: this.addWizardData.description, + color: this.addWizardData.color, + license_type: this.addWizardData.license_type, + required_time: this.addWizardData.required_time, + difficulty_start: this.addWizardData.difficulty_start, + difficulty_end: this.addWizardData.difficulty_end + } + }, + relationships: { + range: { + data: { + type: this.context.type, + id: this.context.id + } + } + } + }; + this.setShowUnitAddDialog(false); + + await this.createCoursewareUnit(unit, { root: true }); + this.companionSuccess({ info: this.$gettext('Neues Lernmaterial angelegt.') }); + const newElementId = this.lastCreateCoursewareUnit.relationships['structural-element'].data.id + await this.loadStructuralElementById({ id: newElementId }); + let newStructuralElement = this.structuralElementById({id: newElementId}); + if (file) { + this.uploadImageForStructuralElement({ + structuralElement: newStructuralElement, + file, + }).then(() => { + this.loadStructuralElementById({id: newStructuralElement.id, options: {include: 'children'}}); + }) + .catch((error) => { + console.error(error); + this.companionError({ info: this.$gettext('Das Bild für das neue Lernmaterial konnte nicht gespeichert werden.') }); + }); + } + } + }, + watch: { + addWizardData: { + handler(newData) { + this.requirements = []; + const slot = this.wizardSlots[0]; + if (newData.title !== '' && newData.description !== '') { + slot.valid = true; + } + if (newData.title === '' ) { + slot.valid = false; + this.requirements.push({slot: slot, text: this.text.title }); + } + if (newData.description === '') { + slot.valid = false; + this.requirements.push({slot: slot, text: this.text.description }); + } + }, + deep: true + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareShelfDialogCopy.vue b/resources/vue/components/courseware/CoursewareShelfDialogCopy.vue new file mode 100644 index 0000000000000000000000000000000000000000..191150fce9a3a756adfcc1de88ed55c725e7adf6 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareShelfDialogCopy.vue @@ -0,0 +1,362 @@ +<template> + <studip-wizard-dialog + :title="$gettext('Lernmaterial kopieren')" + :confirmText="$gettext('Kopieren')" + :closeText="$gettext('Abbrechen')" + :lastRequiredSlotId="2" + :requirements="requirements" + :slots="wizardSlots" + @close="close" + @confirm="copy" + > + <template v-slot:source> + <form class="default" @submit.prevent=""> + <fieldset class="radiobutton-set"> + <template v-if="inCourseContext"> + <input + id="cw-shelf-copy-source-self" + type="radio" + v-model="source" + value="self" + :aria-description="text.sourceSelf" + /> + <label @click="source = 'self'" for="cw-shelf-copy-source-self"> + <div class="icon"><studip-icon shape="seminar" size="32"/></div> + <div class="text">{{ text.sourceSelf }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </template> + <input + id="cw-shelf-copy-source-courses" + type="radio" + v-model="source" + value="courses" + :aria-description="text.sourceCourses" + /> + <label @click="source = 'courses'" for="cw-shelf-copy-source-courses"> + <div class="icon"><studip-icon shape="seminar" size="32"/></div> + <div class="text">{{ text.sourceCourses }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + <input + id="cw-shelf-copy-source-users" + type="radio" + v-model="source" + value="users" + :aria-description="text.sourceUsers" + /> + <label @click="source = 'users'" for="cw-shelf-copy-source-users"> + <div class="icon"><studip-icon shape="content" size="32"/></div> + <div class="text">{{ text.sourceUsers }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </fieldset> + <label v-if="source === 'courses'"> + <span>{{ $gettext('Veranstaltung') }}</span><span aria-hidden="true" class="wizard-required">*</span> + <studip-select + v-if="courses.length !== 0 && !loadingCourses" + :options="courses" + label="title" + :clearable="false" + :reduce="option => option.id" + v-model="selectedRange" + > + <template #open-indicator="selectAttributes"> + <span v-bind="selectAttributes" + ><studip-icon shape="arr_1down" size="10" + /></span> + </template> + <template #no-options="{}"> + {{ $gettext('Es steht keine Auswahl zur Verfügung.') }} + </template> + <template #selected-option="{ attributes }"> + <span>{{ attributes.title }}</span> + </template> + <template #option="{ attributes }"> + <span>{{ attributes.title }}</span> + </template> + </studip-select> + <p v-if="loadingCourses"> + {{$gettext('Lade Veranstaltungen…')}} + </p> + <p v-if="courses.length === 0 && !loadingCourses"> + {{$gettext('Es wurden keine geeigneten Veranstaltungen gefunden.')}} + </p> + </label> + </form> + </template> + <template v-slot:unit> + <form class="default" @submit.prevent=""> + <fieldset v-if="units.length !== 0" class="radiobutton-set"> + <template v-for="unit in units"> + <input + :id="'cw-shelf-copy-unit-' + unit.id" + type="radio" + v-model="selectedUnit" + :checked="unit.id === selectedUnitId" + :value="unit" + :key="'radio-' + unit.id" + :aria-description="unit.element.attributes.title" + /> + <label @click="selectedUnit = unit" :key="'label-' + unit.id" :for="'cw-shelf-copy-unit-' + unit.id"> + <div class="icon"><studip-icon shape="courseware" size="32"/></div> + <div class="text">{{ unit.element.attributes.title }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </template> + </fieldset> + <courseware-companion-box + v-else + mood="sad" + :msgCompanion="$gettext('Für die gewählte Quelle stehen keine Lernmaterialien zur Verfügung.')" + /> + </form> + </template> + <template v-slot:edit> + <form v-if="selectedUnit" class="default" @submit.prevent=""> + <label> + <span>{{$gettext('Titel')}}</span><span aria-hidden="true" class="wizard-required">*</span> + <input type="text" v-model="modifiedTitle" :placeholder="selectedUnitTitle" required /> + </label> + <label> + {{$gettext('Farbe')}} + <studip-select + v-model="modifiedColor" + :options="colors" + :reduce="(color) => color.class" + :clearable="false" + label="class" + > + <template #open-indicator="selectAttributes"> + <span v-bind="selectAttributes" + ><studip-icon shape="arr_1down" size="10" + /></span> + </template> + <template #no-options> + {{ $gettext('Es steht keine Auswahl zur Verfügung.') }} + </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> + <label> + <span>{{$gettext('Beschreibung')}}</span><span aria-hidden="true" class="wizard-required">*</span> + <textarea v-model="modifiedDescription" :placeholder="selectedUnitDescription" required /> + </label> + </form> + <courseware-companion-box + v-else + mood="pointing" + :msgCompanion="$gettext('Bitte wählen Sie ein Lernmaterial aus.')" + /> + </template> + </studip-wizard-dialog> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import colorMixin from '@/vue/mixins/courseware/colors.js'; +import StudipSelect from './../StudipSelect.vue'; +import StudipWizardDialog from './../StudipWizardDialog.vue'; + +import { mapActions, mapGetters } from 'vuex' + +export default { + name: 'courseware-shelf-dialog-copy', + mixins: [colorMixin], + components: { + CoursewareCompanionBox, + StudipWizardDialog, + StudipSelect, + }, + data() { + return { + wizardSlots: [ + { id: 1, valid: false, name: 'source', title: this.$gettext('Quelle'), icon: 'network', + description: this.$gettext('Wählen Sie hier den Ort in Stud.IP aus, an dem sich das zu kopierende Lernmaterial befindet.') }, + { id: 2, valid: false, name: 'unit', title: this.$gettext('Lernmaterial'), icon: 'courseware', + description: this.$gettext('Wählen Sie hier das gewünschte Lernmaterial aus der Liste aus. Eine Auswahl wird durch einen grauen Hintergrund und einen Kontrollhaken angezeigt.') }, + { id: 3, valid: true, name: 'edit', title: this.$gettext('Anpassen'), icon: 'edit', + description: this.$gettext('Sie können hier die Daten des zu kopierenden Lernmaterials anpassen. Eine Anpassung ist optional, Sie können das Lernmaterial auch unverändert kopieren.') }, + ], + source: '', + loadingCourses: false, + courses: [], + selectedRange: '', + loadingUnits: false, + selectedUnit: null, + selectedUnitElement: null, + modifiedTitle: '', + modifiedColor: '', + modifiedDescription: '', + + requirements: [], + text: { + source: this.$gettext('Quelle'), + unit: this.$gettext('Lernmaterial'), + sourceSelf: this.$gettext('Diese Veranstaltung'), + sourceCourses: this.$gettext('Veranstaltung'), + sourceUsers: this.$gettext('Arbeitsplatz'), + + } + } + }, + computed: { + ...mapGetters({ + userId: 'userId', + coursewareUnits: 'courseware-units/all', + structuralElementById: 'courseware-structural-elements/byId', + context: 'context' + }), + colors() { + return this.mixinColors.filter(color => color.darkmode); + }, + units() { + let units = this.coursewareUnits.filter(unit => unit.relationships.range.data.id === this.selectedRange); + units.forEach(unit => { + unit.element = this.getUnitElement(unit); + }); + + if (this.inCourseContext) { + units = units.filter(unit => unit.element.attributes.purpose !== 'template'); + } + + return units; + }, + selectedUnitId() { + return this.selectedUnit?.id; + }, + inCourseContext() { + return this.context.type === 'courses'; + }, + selectedUnitTitle() { + return this.selectedUnitElement.attributes.title ?? ''; + }, + selectedUnitDescription() { + return this.selectedUnitElement.attributes.payload.description ?? ''; + } + }, + async mounted() { + this.initWizardData(); + }, + methods: { + ...mapActions({ + companionSuccess: 'companionSuccess', + loadCourseUnits: 'loadCourseUnits', + loadUsersCourses: 'loadUsersCourses', + loadUserUnits: 'loadUserUnits', + setShowUnitCopyDialog: 'setShowUnitCopyDialog', + copyUnit: 'copyUnit', + }), + initWizardData() { + this.source = this.inCourseContext ? 'self' : 'users'; + this.selectedRange = ''; + this.selectedUnit = null; + }, + close() { + this.setShowUnitCopyDialog(false); + this.initWizardData(); + }, + getUnitElement(unit) { + return this.structuralElementById({id: unit.relationships['structural-element'].data.id}); + }, + async copy() { + if (this.selectedUnit) { + const element = this.getUnitElement(this.selectedUnit); + const modified = { + title: this.modifiedTitle !== '' ? this.modifiedTitle : this.selectedUnitTitle, + color: this.modifiedColor, + description: this.modifiedDescription !== '' ? this.modifiedDescription : this.selectedUnitDescription + } + await this.copyUnit({ unitId: this.selectedUnit.id, modified: modified }); + this.companionSuccess({ info: this.$gettext('Lernmaterial kopiert.') }); + this.close(); + } + }, + async updateCourses() { + this.loadingCourses = true; + this.courses = await this.loadUsersCourses({ userId: this.userId, withCourseware: true }); + this.loadingCourses = false; + }, + async updateCourseUnits(cid) { + this.loadingUnits = true; + await this.loadCourseUnits(cid); + this.loadingUnits = false; + }, + setElementData() { + this.selectedUnitElement = this.getUnitElement(this.selectedUnit); + this.modifiedTitle = this.selectedUnitElement.attributes.title; + this.modifiedColor = this.selectedUnitElement.attributes.payload.color; + this.modifiedDescription = this.selectedUnitElement.attributes.payload.description; + }, + resetElementData() { + this.modifiedTitle = ''; + this.modifiedColor = ''; + this.modifiedDescription = ''; + }, + validateSelection() { + this.requirements = []; + if (this.selectedRange === '') { + this.requirements.push({slot: this.wizardSlots[0], text: this.text.source }); + } + if (this.selectedUnit === null) { + this.requirements.push({slot: this.wizardSlots[1], text: this.text.unit }); + } + } + }, + watch: { + selectedUnit(newUnit) { + this.validateSelection(); + const slot = this.wizardSlots[1]; + if (newUnit !== null) { + slot.valid = true; + this.setElementData(); + } else { + slot.valid = false; + this.resetElementData(); + } + }, + selectedRange(newRid) { + this.selectedUnit = null; + this.validateSelection(); + const slot = this.wizardSlots[0]; + + if (newRid !== '') { + slot.valid = true; + if (this.source === 'courses' || this.source === 'self') { + this.updateCourseUnits(newRid); + } + if (this.source === 'users') { + this.loadUserUnits(newRid); + } + } else { + slot.valid = false; + } + }, + source(newSource) { + switch (newSource) { + case 'self': + this.selectedRange = this.context.id; + break; + case 'courses': + this.selectedRange = ''; + this.updateCourses(); + break; + case 'users': + this.selectedRange = this.userId; + break; + } + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareShelfDialogImport.vue b/resources/vue/components/courseware/CoursewareShelfDialogImport.vue new file mode 100644 index 0000000000000000000000000000000000000000..171f34dfc48a2a089921b21b622eaab7dea77d69 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareShelfDialogImport.vue @@ -0,0 +1,371 @@ +<template> + <div class="cw-shelf-dialog-import-wrapper"> + <studip-wizard-dialog + v-if="!importRunning" + :title="$gettext('Lernmaterial importieren')" + :confirmText="$gettext('Importieren')" + :closeText="$gettext('Abbrechen')" + :slots="wizardSlots" + :lastRequiredSlotId="1" + :requirements="requirements" + @close="setShowUnitImportDialog(false)" + @confirm="importCoursewareArchiv" + > + <template v-slot:file> + <form class="default" @submit.prevent=""> + <label> + <span>{{ text.import }}</span><span aria-hidden="true" class="wizard-required">*</span> + <input v-show="importZipFile === null" ref="fileInput" class="cw-file-input" type="file" accept=".zip" @change="setImport" /> + <p v-show="importZipFile !== null" class="cw-file-input-change"> + <button class="button" @click="$refs.fileInput.click()">{{ $gettext('Datei ändern')}}</button><span>{{ importZipFile?.name }}</span> + </p> + </label> + <fieldset v-show="archiveErrors.length > 0"> + <legend>{{$gettext('Fehler im Import-Archiv')}}</legend> + <ul> + <li v-for="(error, index) in archiveErrors" :key="index"> {{error}} </li> + </ul> + </fieldset> + </form> + </template> + <template v-slot:edit> + <form v-if="hasValidFile" class="default" @submit.prevent=""> + <label> + {{ text.title }} + <input type="text" v-model="modifiedData.title" :placeholder="loadedTitle" required /> + </label> + <label> + {{ text.color }} + <studip-select + v-model="modifiedData.color" + :options="colors" + :reduce="(color) => color.class" + :clearable="false" + label="class" + > + <template #open-indicator="selectAttributes"> + <span v-bind="selectAttributes" + ><studip-icon shape="arr_1down" size="10" + /></span> + </template> + <template #no-options> + {{$gettext('Es steht keine Auswahl zur Verfügung.')}} + </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> + <label> + {{ text.description }} + <textarea v-model="modifiedData.description" :placeholder="loadedDescription" required /> + </label> + </form> + <courseware-companion-box + v-else + :msgCompanion="$gettext('Bitte wählen Sie ein Import-Archiv aus.')" + mood="unsure" + /> + </template> + </studip-wizard-dialog> + <studip-dialog + v-if="importRunning" + :title="$gettext('Lernmaterial importieren')" + :closeText="$gettext('Schließen')" + height="420" + @close="setShowUnitImportDialog(false)" + > + <template v-slot:dialogContent> + <div role="status" aria-live="polite"> + <courseware-companion-box + v-show="importDone && importErrors.length === 0" + :msgCompanion="$gettext('Import erfolgreich!')" + mood="special" + /> + <courseware-companion-box + v-show="importDone && importErrors.length > 0" + :msgCompanion="$gettext('Import abgeschlossen. Es sind Fehler aufgetreten!')" + mood="unsure" + /> + <courseware-companion-box + v-show="!importDone" + :msgCompanion="$gettext('Import läuft. Bitte schließen Sie den Dialog nicht bis der Import abgeschlossen wurde.')" + mood="pointing" + /> + </div> + <form v-if="!importDone" class="default" @submit.prevent=""> + <fieldset> + <div v-if="!fileImportDone" class="cw-import-zip"> + <header>{{$gettext('Importiere Dateien')}}:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: importFilesProgress + '%'}" :aria-valuenow="importFilesProgress" aria-valuemin="0" aria-valuemax="100">{{ importFilesProgress }}%</div> + </div> + {{ importFilesState }} + </div> + <div v-if="fileImportDone" class="cw-import-zip"> + <header>{{$gettext('Importiere Elemente')}}:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: importStructuresProgress + '%'}" :aria-valuenow="importStructuresProgress" aria-valuemin="0" aria-valuemax="100">{{ importStructuresProgress }}%</div> + </div> + {{ importStructuresState }} + </div> + </fieldset> + <fieldset v-show="importErrors.length > 0"> + <legend>{{$gettext('Fehlermeldungen')}}</legend> + <ul> + <li v-for="(error, index) in importErrors" :key="index"> {{error}} </li> + </ul> + </fieldset> + </form> + </template> + </studip-dialog> + </div> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import CoursewareImport from '@/vue/mixins/courseware/import.js'; +import colorMixin from '@/vue/mixins/courseware/colors.js'; +import StudipWizardDialog from './../StudipWizardDialog.vue'; + +import { mapActions, mapGetters } from 'vuex' +import JSZip from 'jszip'; + +export default { + name: 'courseware-shelf-dialog-import', + components: { + StudipWizardDialog, + CoursewareCompanionBox + }, + mixins: [CoursewareImport, colorMixin], + data() { + return { + wizardSlots: [ + { id: 1, valid: false, name: 'file', title: this.$gettext('Import-Archiv'), icon: 'file-archive', + description: this.$gettext('Wählen Sie hier eine Courseware-Export-Archiv-Datei von Ihrer Festplatte aus. Bei Courseware-Export-Archiven handelt es sich um Zip-Dateien. Diese sollten mindestens die Dateien files.json und courseware.json enthalten.') }, + { id: 2, valid: true, name: 'edit', title: this.$gettext('Anpassen'), icon: 'edit', description: this.$gettext('Sie können hier die Daten des zu importierenden Lernmaterials anpassen. Eine Anpassung ist optional, Sie können das Archiv auch unverändert importieren.') }, + ], + modifiedData: { + title: '', + color: 'studip-blue', + description: '' + }, + importArchivFile: null, + importRunning: false, + importZipFile: null, + zip: null, + + loadedZipData: null, + archiveErrors: [], + + requirements: [], + text: { + import: this.$gettext('Importdatei'), + title: this.$gettext('Titel'), + color: this.$gettext('Farbe'), + description: this.$gettext('Beschreibung'), + } + } + }, + computed: { + ...mapGetters({ + context: 'context', + importFilesState: 'importFilesState', + importFilesProgress: 'importFilesProgress', + importStructuresState: 'importStructuresState', + importStructuresProgress: 'importStructuresProgress', + importErrors: 'importErrors', + lastCreateCoursewareUnit: 'courseware-units/lastCreated', + + }), + colors() { + return this.mixinColors.filter(color => color.darkmode); + }, + fileImportDone() { + return this.importFilesProgress === 100; + }, + importDone() { + return this.importFilesProgress === 100 && this.importStructuresProgress === 100; + }, + hasValidFile() { + return this.archiveErrors.length === 0 && this.loadedZipData !== null; + }, + loadedTitle() { + return this.loadedZipData.courseware.attributes.title ?? ''; + }, + loadedDescription() { + return this.loadedZipData.courseware.attributes.payload.description ?? ''; + } + }, + methods: { + ...mapActions({ + setShowUnitImportDialog: 'setShowUnitImportDialog', + createCoursewareUnit: 'courseware-units/create', + setImportFilesProgress: 'setImportFilesProgress', + setImportStructuresProgress: 'setImportStructuresProgress', + setImportErrors: 'setImportErrors', + loadStructuralElementById: 'courseware-structural-elements/loadById', + companionSuccess: 'companionSuccess', + }), + setImport(event) { + this.importZipFile = event.target.files[0]; + this.loadZipData(); + }, + + async loadZipData() { + const slot = this.wizardSlots[0]; + const text = this.text.import; + this.archiveErrors = []; + this.loadedZipData = null; + this.modifiedData.title = ''; + this.modifiedData.color = 'studip-blue'; + this.modifiedData.description = ''; + let filesError = false; + if (!this.importZipFile.type.includes('zip')) { + this.archiveErrors.push(this.$gettext('Die gewählte Datei ist kein Archiv.')); + filesError = true; + } + if (!filesError) { + try { + this.zip = await JSZip.loadAsync(this.importZipFile); + } catch(error) { + this.zip = null; + this.archiveErrors.push(this.$gettext('Beim laden des Archivs ist ein Fehler aufgetreten. Vermutlich ist das Archiv beschädigt.')); + filesError = true; + } + + if (this.zip) { + if (this.zip.file('courseware.json') === null) { + this.archiveErrors.push(this.$gettext('Das Archiv enthält keine courseware.json Datei.')); + filesError = true; + } + if (this.zip.file('files.json') === null) { + this.archiveErrors.push(this.$gettext('Das Archiv enthält keine files.json Datei.')); + filesError = true; + } + if (this.zip.file('data.xml') !== null) { + this.archiveErrors.push(this.$gettext( + 'Das Archiv enthält eine data.xml Datei. Möglicherweise handelt es sich um einen Export aus dem Courseware-Plugin. Diese Archive sind nicht kompatibel mit dieser Courseware.' + )); + filesError = true; + } + } + } + if (filesError) { + this.updateRequirements(slot, text, false); + slot.valid = false; + return; + } else { + this.updateRequirements(slot, text, true); + slot.valid = true; + } + + let data = await this.zip.file('courseware.json').async('string'); + let courseware = null; + let data_settings = null; + let settings = null; + let data_files = await this.zip.file('files.json').async('string'); + let files = null; + let jsonErrors = false; + + try { + courseware = JSON.parse(data); + } catch (error) { + jsonErrors = true; + this.archiveErrors.push(this.$gettext('Die Beschreibung der Courseware-Inhalte ist nicht valide.')); + this.archiveErrors.push(error); + } + + if (this.zip.file('settings.json') !== null) { + data_settings = await this.zip.file('settings.json').async('string'); + try { + settings = JSON.parse(data_settings); + } catch (error) { + jsonErrors = true; + this.archiveErrors.push(this.$gettext('Die Beschreibung der Courseware-Einstellungen ist nicht valide.')); + this.archiveErrors.push(error); + } + } + + try { + files = JSON.parse(data_files); + } catch (error) { + jsonErrors = true; + this.archiveErrors.push(this.$gettext('Die Beschreibung der Dateien ist nicht valide.')); + this.archiveErrors.push(error); + } + if (jsonErrors) { + return; + } + + this.loadedZipData = { + courseware: courseware, + files: files, + settings: settings + } + + this.modifiedData.title = courseware.attributes.title; + this.modifiedData.color = courseware.attributes.payload.color ?? 'studip-blue'; + this.modifiedData.description = courseware.attributes.payload.description ?? ''; + }, + + async importCoursewareArchiv() { + if (this.loadedZipData === null) { + return false; + } + + this.setImportFilesProgress(0); + this.setImportStructuresProgress(0); + this.setImportErrors([]); + + this.importRunning = true; + + const title = this.modifiedData.title !== '' ? this.modifiedData.title : this.loadedTitle; + const description = this.modifiedData.description !== '' ? this.modifiedData.description : this.loadedDescription; + + const unit = { + attributes: { + title: title, + payload: { + description: description, + color: this.modifiedData.color, + } + }, + relationships: { + range: { + data: { + type: this.context.type, + id: this.context.id + } + } + } + }; + await this.createCoursewareUnit(unit, { root: true }); + const newElementId = this.lastCreateCoursewareUnit.relationships['structural-element'].data.id; + await this.loadStructuralElementById({ id: newElementId }); + + const newStructuralElement = this.structuralElementById({id: newElementId}); + + await this.importCourseware(this.loadedZipData.courseware, newStructuralElement.id, this.loadedZipData.files, 'migrate', this.loadedZipData.settings); + this.companionSuccess({ info: this.$gettext('Lernmaterial importiert.') }); + }, + updateRequirements(slot, text, valid) { + const index = this.requirements.findIndex(req => req.slot.id === slot.id && req.text === text); + if (valid) { + if (index !== -1) { + this.requirements.splice(index, 1); + } + } else { + if (index === -1) { + this.requirements.push({slot: slot, text: text}); + } + } + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareShelfImportWidget.vue b/resources/vue/components/courseware/CoursewareShelfImportWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..2c946e2a0bb6b55e686e0a9c3f437c5b8ffbbda9 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareShelfImportWidget.vue @@ -0,0 +1,37 @@ +<template> + <sidebar-widget :title="$gettext('Import')"> + <template #content> + <ul class="widget-list widget-links cw-import-widget"> + <li class="cw-import-widget-archive"> + <button @click="setShowUnitImportDialog(true)"> + {{ $gettext('Lernmaterial importieren') }} + </button> + </li> + <li class="cw-import-widget-copy"> + <button @click="setShowUnitCopyDialog(true)"> + {{ $gettext('Lernmaterial kopieren') }} + </button> + </li> + </ul> + </template> + </sidebar-widget> +</template> + +<script> +import SidebarWidget from '../SidebarWidget.vue'; + +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-shelf-import-widget', + components: { + SidebarWidget, + }, + methods: { + ...mapActions({ + setShowUnitCopyDialog: 'setShowUnitCopyDialog', + setShowUnitImportDialog: 'setShowUnitImportDialog', + }), + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index 474bad1d6187e1001d2a265426d3f30f25e7be1c..7057cd4fbd95c0431a321d4753a0571ef394cb0f 100644 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -89,7 +89,7 @@ :canEdit="canEdit" :noContainers="noContainers" /> - <courseware-wellcome-screen v-if="noContainers && isRoot && canEdit" /> + <courseware-welcome-screen v-if="noContainers && isRoot && canEdit" /> </div> <div @@ -357,6 +357,7 @@ :closeText="$gettext('Schließen')" closeClass="cancel" class="cw-structural-element-dialog" + :height="inCourse ? '300' : '430'" @close="closeAddDialog" @confirm="createElement" > @@ -375,6 +376,30 @@ <translate>Name der neuen Seite</translate><br /> <input v-model="newChapterName" type="text" /> </label> + <label v-if="!inCourse"> + <translate>Art des Lernmaterials</translate> + <select v-model="newChapterPurpose"> + <option value="content"><translate>Inhalt</translate></option> + <option v-if="!inCourse" 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 v-if="!inCourse"> + <translate>Lernmaterialvorlage</translate> + <select v-model="newChapterTemplate"> + <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> </template> </studip-dialog> @@ -587,15 +612,15 @@ @close="closeDeleteDialog" ></studip-dialog> <studip-dialog - v-if="showLinkDialog" + v-if="showPublicLinkDialog && inContent" :title="$gettext('Öffentlichen Link für Seite erzeugen')" :confirmText="$gettext('Erstellen')" confirmClass="accept" - :closeText="$gettext('Schließen')" + :closeText="$gettext('Abbrechen')" closeClass="cancel" class="cw-structural-element-dialog" - @close="closeLinkDialog" - @confirm="createElementLink" + @close="closePublicLinkDialog" + @confirm="createElementPublicLink" > <template v-slot:dialogContent> <form class="default" @submit.prevent=""> @@ -619,6 +644,10 @@ @confirm="executeRemoveLock" @close="showElementRemoveLockDialog(false)" ></studip-dialog> + + <courseware-structural-element-dialog-import v-if="showImportDialog"/> + <courseware-structural-element-dialog-copy v-if="showCopyDialog" /> + <courseware-structural-element-dialog-link v-if="showLinkDialog"/> </div> <div v-else> <courseware-companion-box @@ -634,12 +663,15 @@ <script> import ContainerComponents from './container-components.js'; import CoursewarePluginComponents from './plugin-components.js'; +import CoursewareStructuralElementDialogCopy from './CoursewareStructuralElementDialogCopy.vue'; +import CoursewareStructuralElementDialogImport from './CoursewareStructuralElementDialogImport.vue'; +import CoursewareStructuralElementDialogLink from './CoursewareStructuralElementDialogLink.vue'; +import CoursewareStructuralElementDiscussion from './CoursewareStructuralElementDiscussion.vue'; 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'; -import CoursewareWellcomeScreen from './CoursewareWellcomeScreen.vue'; +import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue'; import CoursewareEmptyElementBox from './CoursewareEmptyElementBox.vue'; import CoursewareListContainer from './CoursewareListContainer.vue'; import CoursewareTabsContainer from './CoursewareTabsContainer.vue'; @@ -648,6 +680,7 @@ import CoursewareTabs from './CoursewareTabs.vue'; import CoursewareTab from './CoursewareTab.vue'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import CoursewareOerMessage from '@/vue/mixins/courseware/oermessage.js'; +import colorMixin from '@/vue/mixins/courseware/colors.js'; import CoursewareDateInput from './CoursewareDateInput.vue'; import { FocusTrap } from 'focus-trap-vue'; import IsoDate from './IsoDate.vue'; @@ -658,6 +691,9 @@ import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-structural-element', components: { + CoursewareStructuralElementDialogCopy, + CoursewareStructuralElementDialogImport, + CoursewareStructuralElementDialogLink, CoursewareStructuralElementDiscussion, CoursewareStructuralElementPermissions, CoursewareContentPermissions, @@ -666,7 +702,7 @@ export default { CoursewareAccordionContainer, CoursewareTabsContainer, CoursewareCompanionBox, - CoursewareWellcomeScreen, + CoursewareWelcomeScreen, CoursewareEmptyElementBox, CoursewareTabs, CoursewareTab, @@ -678,12 +714,14 @@ export default { }, props: ['canVisit', 'orderedStructuralElements', 'structuralElement'], - mixins: [CoursewareExport, CoursewareOerMessage], + mixins: [CoursewareExport, CoursewareOerMessage, colorMixin], data() { return { newChapterName: '', newChapterParent: 'descendant', + newChapterPurpose: 'content', + newChapterTemplate: null, currentElement: '', uploadFileError: '', textCompanionWrongContext: this.$gettext('Die angeforderte Seite ist nicht Teil dieser Courseware.'), @@ -762,13 +800,16 @@ export default { pluginManager: 'pluginManager', showEditDialog: 'showStructuralElementEditDialog', showAddDialog: 'showStructuralElementAddDialog', + showImportDialog: 'showStructuralElementImportDialog', + showCopyDialog: 'showStructuralElementCopyDialog', + showLinkDialog: 'showStructuralElementLinkDialog', showExportDialog: 'showStructuralElementExportDialog', showPdfExportDialog: 'showStructuralElementPdfExportDialog', showInfoDialog: 'showStructuralElementInfoDialog', showDeleteDialog: 'showStructuralElementDeleteDialog', showOerDialog: 'showStructuralElementOerDialog', showSuggestOerDialog: 'showSuggestOerDialog', - showLinkDialog: 'showStructuralElementLinkDialog', + showPublicLinkDialog: 'showStructuralElementPublicLinkDialog', showRemoveLockDialog: 'showStructuralElementRemoveLockDialog', oerEnabled: 'oerEnabled', licenses: 'licenses', @@ -778,11 +819,14 @@ export default { viewMode: 'viewMode', taskById: 'courseware-tasks/byId', userById: 'users/byId', + lastCreatedElement: 'courseware-structural-elements/lastCreated', blocked: 'currentElementBlocked', blockerId: 'currentElementBlockerId', blockedByThisUser: 'currentElementBlockedByThisUser', blockedByAnotherUser: 'currentElementBlockedByAnotherUser', + + templates: 'courseware-templates/all', }), currentId() { @@ -791,27 +835,27 @@ export default { textOer() { return { - title: this.$gettext('Seite auf dem OER Campus veröffentlichen'), + title: this.$gettext('Lerninhalte auf dem OER Campus veröffentlichen'), confirm: this.$gettext('Veröffentlichen'), - close: this.$gettext('Schließen'), + close: this.$gettext('Abbrechen'), }; }, textSuggestOer() { return { - title: this.$gettext('Material für den OER Campus vorschlagen'), - confirm: this.$gettext('Material vorschlagen'), - close: this.$gettext('Schließen'), + title: this.$gettext('Lerninhalt für den OER Campus vorschlagen'), + confirm: this.$gettext('Lerninhalt vorschlagen'), + close: this.$gettext('Abbrechen'), }; }, inCourse() { - return this.$store.getters.context.type === 'courses'; + return this.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; + return this.context.type === 'users' && this.userId === this.currentElement.relationships.user.data.id; }, textDelete() { @@ -831,31 +875,30 @@ export default { validContext() { let valid = false; - let context = this.$store.getters.context; - if (context.type === 'courses' && this.currentElement.relationships) { + if (this.context.type === 'courses' && this.currentElement.relationships) { if ( this.currentElement.relationships.course && - context.id === this.currentElement.relationships.course.data.id + this.context.id === this.currentElement.relationships.course.data.id ) { valid = true; } } - if (context.type === 'users' && this.currentElement.relationships) { + if (this.context.type === 'users' && this.currentElement.relationships) { if ( this.currentElement.relationships.user && - context.id === this.currentElement.relationships.user.data.id + this.context.id === this.currentElement.relationships.user.data.id ) { valid = true; } } - if (context.type === 'sharedusers') { - if (context.id === this.courseware.relationships.root.data.id) { + if (this.context.type === 'sharedusers') { + if (this.context.id === this.courseware.relationships.root.data.id) { valid = true; } } - if (context.type === 'public') { + if (this.context.type === 'public') { valid = true; } @@ -988,7 +1031,7 @@ export default { let menu = [ { id: 4, label: this.$gettext('Informationen anzeigen'), icon: 'info', emit: 'showInfo' }, { id: 5, label: this.$gettext('Lesezeichen setzen'), icon: 'star', emit: 'setBookmark' }, - { id: 6, label: this.$gettext('Material für den OER Campus vorschlagen'), icon: 'oer-campus', emit: 'showSuggest' }, + { id: 6, label: this.$gettext('Lerninhalt für OER Campus vorschlagen'), icon: 'oer-campus', emit: 'showSuggest' }, ]; if (this.canEdit) { @@ -1026,154 +1069,7 @@ export default { return menu; }, colors() { - const colors = [ - { - name: this.$gettext('Schwarz'), - class: 'black', - hex: '#000000', - level: 100, - icon: 'black', - darkmode: true, - }, - { - name: this.$gettext('Weiß'), - class: 'white', - hex: '#ffffff', - level: 100, - icon: 'white', - darkmode: false, - }, - - { - name: this.$gettext('Blau'), - class: 'studip-blue', - hex: '#28497c', - level: 100, - icon: 'blue', - darkmode: true, - }, - { - name: this.$gettext('Hellblau'), - class: 'studip-lightblue', - hex: '#e7ebf1', - level: 40, - icon: 'lightblue', - darkmode: false, - }, - { - name: this.$gettext('Rot'), - class: 'studip-red', - hex: '#d60000', - level: 100, - icon: 'red', - darkmode: false, - }, - { - name: this.$gettext('Grün'), - class: 'studip-green', - hex: '#008512', - level: 100, - icon: 'green', - darkmode: true, - }, - { - name: this.$gettext('Gelb'), - class: 'studip-yellow', - hex: '#ffbd33', - level: 100, - icon: 'yellow', - darkmode: false, - }, - { - name: this.$gettext('Grau'), - class: 'studip-gray', - hex: '#636a71', - level: 100, - icon: 'grey', - darkmode: true, - }, - - { - name: this.$gettext('Holzkohle'), - class: 'charcoal', - hex: '#3c454e', - level: 100, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Königliches Purpur'), - class: 'royal-purple', - hex: '#8656a2', - level: 80, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Leguangrün'), - class: 'iguana-green', - hex: '#66b570', - level: 60, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Königin blau'), - class: 'queen-blue', - hex: '#536d96', - level: 80, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Helles Seegrün'), - class: 'verdigris', - hex: '#41afaa', - level: 80, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Maulbeere'), - class: 'mulberry', - hex: '#bf5796', - level: 80, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Kürbis'), - class: 'pumpkin', - hex: '#f26e00', - level: 100, - icon: false, - darkmode: true, - }, - { - name: this.$gettext('Sonnenschein'), - class: 'sunglow', - hex: '#ffca5c', - level: 80, - icon: false, - darkmode: false, - }, - { - name: this.$gettext('Apfelgrün'), - class: 'apple-green', - hex: '#8bbd40', - level: 80, - icon: false, - darkmode: true, - }, - ]; - let elementColors = []; - colors.forEach((color) => { - if (color.darkmode) { - elementColors.push(color); - } - }); - - return elementColors; + return this.mixinColors.filter(color => color.darkmode); }, currentLicenseName() { for (let i = 0; i < this.licenses.length; i++) { @@ -1313,6 +1209,11 @@ export default { ownerName() { return this.owner?.attributes['formatted-name'] ?? '?'; }, + selectableTemplates() { + return this.templates.filter(template => { + return template.attributes.purpose === this.newElementPurpose + }); + }, }, methods: { @@ -1336,7 +1237,7 @@ export default { showElementInfoDialog: 'showElementInfoDialog', showElementDeleteDialog: 'showElementDeleteDialog', showElementOerDialog: 'showElementOerDialog', - showElementLinkDialog: 'showElementLinkDialog', + showElementPublicLinkDialog: 'showElementPublicLinkDialog', showElementRemoveLockDialog: 'showElementRemoveLockDialog', updateShowSuggestOerDialog: 'updateShowSuggestOerDialog', updateContainer: 'updateContainer', @@ -1415,7 +1316,7 @@ export default { this.setBookmark(); break; case 'linkElement': - this.showElementLinkDialog(true); + this.showElementPublicLinkDialog(true); break; } }, @@ -1610,11 +1511,11 @@ export default { }) .catch(error => { this.companionError({ info: this.$gettext('Die Seite konnte nicht gelöscht werden.') }); - console.debug(error); }); }, - createElement() { - let title = this.newChapterName; // this is the title of the new element + async createElement() { + const title = this.newChapterName; // this is the title of the new element + const purpose = this.newChapterPurpose; let parent_id = this.currentId; // new page is descandant as default let writeApproval = this.currentElement.attributes['write-approval']; let readApproval = this.currentElement.attributes['read-approval']; @@ -1632,9 +1533,11 @@ export default { this.createStructuralElement({ attributes: { title: title, + purpose: purpose, 'write-approval': writeApproval, 'read-approval': readApproval }, + templateId: this.newChapterTemplate ? this.newChapterTemplate.id : null, parentId: parent_id, currentId: this.currentId, }) @@ -1657,6 +1560,13 @@ export default { this.companionError({ info: errorMessage }); }); + let newElement = this.lastCreatedElement; + this.companionSuccess({ + info: this.$gettextInterpolate( + this.$gettext('Die Seite %{ pageTitle } wurde erfolgreich angelegt.'), + {pageTitle: newElement.attributes.title} + ) + }); this.newChapterName = ''; }, containerComponent(container) { @@ -1676,7 +1586,7 @@ export default { this.suggestViaAction(this.currentElement, this.additionalText); this.updateShowSuggestOerDialog(false); }, - async createElementLink() { + async createElementPublicLink() { const date = this.publicLink['expire-date']; const publicLink = { attributes: { @@ -1699,12 +1609,12 @@ export default { }); this.closeLinkDialog(); }, - closeLinkDialog() { + closePublicLinkDialog() { this.publicLink = { passsword: '', 'expire-date': '' }; - this.showElementLinkDialog(false); + this.showElementPublicLinkDialog(false); }, displayRemoveLockDialog() { this.showElementRemoveLockDialog(true); diff --git a/resources/vue/components/courseware/CoursewareStructuralElementComments.vue b/resources/vue/components/courseware/CoursewareStructuralElementComments.vue index da6de348159bebcde9f53ef27b98411feb71e42e..197d1bfe066c0f2f9f3f4a4caeb71b276ac6e4ee 100644 --- a/resources/vue/components/courseware/CoursewareStructuralElementComments.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElementComments.vue @@ -19,7 +19,7 @@ <script> import CoursewareTalkBubble from './CoursewareTalkBubble.vue'; -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-structural-element-comments', @@ -58,12 +58,16 @@ export default { } }, methods: { + ...mapActions({ + createComments: 'courseware-structural-element-comments/create', + loadRelatedComments: 'courseware-structural-element-comments/loadRelated' + }), async loadComments() { const parent = { type: this.structuralElement.type, id: this.structuralElement.id, }; - await this.$store.dispatch('courseware-structural-element-comments/loadRelated', { + await this.loadRelatedComments({ parent, relationship: 'comments', options: { @@ -86,7 +90,7 @@ export default { } }; - await this.$store.dispatch('courseware-structural-element-comments/create', data); + await this.createComments(data); this.loadComments(); this.createComment = ''; }, diff --git a/resources/vue/components/courseware/CoursewareStructuralElementDialogCopy.vue b/resources/vue/components/courseware/CoursewareStructuralElementDialogCopy.vue new file mode 100644 index 0000000000000000000000000000000000000000..35548bcb455ca51e8e489f8f3e7723c77f010ce1 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareStructuralElementDialogCopy.vue @@ -0,0 +1,466 @@ +<template> + <studip-wizard-dialog + :title="$gettext('Lerninhalte kopieren')" + :confirmText="$gettext('Kopieren')" + :closeText="$gettext('Abbrechen')" + :lastRequiredSlotId="3" + :requirements="requirements" + :slots="wizardSlots" + @close="showElementCopyDialog(false)" + @confirm="copyElement" + > + <template v-slot:source> + <form class="default" @submit.prevent=""> + <fieldset class="radiobutton-set"> + <input + id="cw-element-copy-source-self" + v-if="inCourseContext" + type="radio" + v-model="source" + value="self" + :aria-description="text.sourceSelf" + /> + <label v-if="inCourseContext" @click="source = 'self'" for="cw-element-copy-source-self"> + <div class="icon"><studip-icon shape="seminar" size="32"/></div> + <div class="text">{{ text.sourceSelf }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + <input + id="cw-element-copy-source-courses" + type="radio" + v-model="source" + value="courses" + :aria-description="text.sourceCourses" + /> + <label @click="source = 'courses'" for="cw-element-copy-source-courses"> + <div class="icon"><studip-icon shape="seminar" size="32"/></div> + <div class="text">{{ text.sourceCourses }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + <input + id="cw-element-copy-source-users" + type="radio" + v-model="source" + value="users" + :aria-description="text.sourceUsers" + /> + <label @click="source = 'users'" for="cw-element-copy-source-users"> + <div class="icon"><studip-icon shape="content" size="32"/></div> + <div class="text">{{ text.sourceUsers }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </fieldset> + <label v-if="source === 'courses'"> + <span>{{ $gettext('Veranstaltung') }}</span><span aria-hidden="true" class="wizard-required">*</span> + <studip-select + v-if="courses.length !== 0 && !loadingCourses" + :options="courses" + label="title" + :clearable="false" + :reduce="option => option.id" + v-model="selectedRange" + > + <template #open-indicator="selectAttributes"> + <span v-bind="selectAttributes" + ><studip-icon shape="arr_1down" size="10" + /></span> + </template> + <template #no-options="{}"> + {{ $gettext('Es steht keine Auswahl zur Verfügung.') }} + </template> + <template #selected-option="{ attributes }"> + <span>{{ attributes.title }}</span> + </template> + <template #option="{ attributes }"> + <span>{{ attributes.title }}</span> + </template> + </studip-select> + <p v-if="loadingCourses"> + {{$gettext('Lade Veranstaltungen…')}} + </p> + <p v-if="courses.length === 0 && !loadingCourses"> + {{$gettext('Es wurden keine geeigneten Veranstaltungen gefunden.')}} + </p> + </label> + </form> + </template> + <template v-slot:unit> + <form class="default" @submit.prevent=""> + <fieldset v-if="units.length !== 0" class="radiobutton-set"> + <template v-for="unit in units"> + <input + :id="'cw-element-copy-unit-' + unit.id" + type="radio" + :checked="unit.id === selectedUnitId" + :value="unit.id" + :key="'radio-' + unit.id" + :aria-description="unit.element.attributes.title" + /> + <label @click="selectedUnit = unit" :key="'label-' + unit.id" :for="'cw-shelf-copy-unit-' + unit.id"> + <div class="icon"><studip-icon shape="courseware" size="32"/></div> + <div class="text">{{ unit.element.attributes.title }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </template> + </fieldset> + <courseware-companion-box + v-else + mood="sad" + :msgCompanion="$gettext('Für die gewählte Quelle stehen kein Lernmaterialien zur Verfügung.')" + /> + </form> + </template> + <template v-slot:element> + <form v-if="selectedUnit" class="default" @submit.prevent=""> + <fieldset class="radiobutton-set"> + <input id="cw-element-copy-element" type="radio" checked :aria-description="selectedElementTitle" /> + <label for="cw-element-copy-element" @click="e => e.preventDefault()"> + <div class="icon"><studip-icon shape="content2" size="32"/></div> + <div class="text">{{ selectedElementTitle }}</div> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </fieldset> + <button + v-if="selectedElementParent" + class="button" + @click="selectElement(selectedElementParent.id)" + > + {{ $gettextInterpolate( + $gettext('zurück zu %{ parentTitle }'), + { parentTitle: selectedElementParentTitle } + ) }} + </button> + <fieldset> + <legend>{{ $gettext('Unterseiten') }}</legend> + <ul class="cw-element-selector-list"> + <li + v-for="child in children" + :key="child.id" + > + <button + class="cw-element-selector-item" + @click="selectElement(child.id)" + > + {{ child.attributes.title }} + </button> + </li> + <li v-if="children.length === 0"> + {{ $gettext('Es wurden keine Unterseiten gefunden') }} + </li> + </ul> + </fieldset> + </form> + <courseware-companion-box + v-else + mood="pointing" + :msgCompanion="$gettext('Bitte wählen Sie ein Lernmaterial aus.')" + /> + </template> + <template v-slot:edit> + <form v-if="selectedUnit" class="default" @submit.prevent=""> + <label> + {{$gettext('Titel')}} + <input type="text" v-model="modifiedTitle" required /> + </label> + <label> + {{$gettext('Farbe')}} + <studip-select + v-model="modifiedColor" + :options="colors" + :reduce="(color) => color.class" + :clearable="false" + label="class" + > + <template #open-indicator="selectAttributes"> + <span v-bind="selectAttributes" + ><studip-icon shape="arr_1down" size="10" + /></span> + </template> + <template #no-options> + {{ $gettext('Es steht keine Auswahl zur Verfügung.') }} + </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> + <label> + {{$gettext('Beschreibung')}} + <textarea v-model="modifiedDescription" required /> + </label> + </form> + <courseware-companion-box + v-else + mood="pointing" + :msgCompanion="$gettext('Bitte wählen Sie eine Seite aus.')" + /> + </template> + </studip-wizard-dialog> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import colorMixin from '@/vue/mixins/courseware/colors.js'; +import StudipSelect from './../StudipSelect.vue'; +import StudipWizardDialog from './../StudipWizardDialog.vue'; + +import { mapActions, mapGetters } from 'vuex' + +export default { + name: 'courseware-structural-element-dialog-copy', + mixins: [colorMixin], + components: { + CoursewareCompanionBox, + StudipWizardDialog, + StudipSelect, + }, + data() { + return { + wizardSlots: [ + { id: 1, valid: false, name: 'source', title: this.$gettext('Quelle'), icon: 'network', + description: this.$gettext('Wählen Sie hier den Ort in Stud.IP aus, an dem sich der zu kopierende Lerninhalt befindet.') }, + { id: 2, valid: false, name: 'unit', title: this.$gettext('Lernmaterial'), icon: 'courseware', + description: this.$gettext('Wählen Sie das Lernmaterial aus, in dem sich der zu kopierende Lerninhalt befindet.') }, + { id: 3, valid: false, name: 'element', title: this.$gettext('Seite'), icon: 'content2', + description: this.$gettext('Wählen Sie die zu kopierende Seite aus. Vorausgewählt ist die oberste Seite des ausgewählten Lernmaterials. Unterseiten erreichen Sie über die Schaltflächen im Bereich "Unterseiten". Sie können über die "zurück zu" Schaltfläche das übergeordnete Element anwählen. Die ausgewählte Seite ist mit einem Kontrollhaken markiert.') }, + { id: 4, valid: true, name: 'edit', title: this.$gettext('Anpassen'), icon: 'edit', + description: this.$gettext('Sie können hier die Daten der zu kopierenden Seite anpassen. Eine Anpassung ist optional, Sie können die Seite auch unverändert kopieren.') }, + ], + source: '', + loadingCourses: false, + courses: [], + selectedRange: '', + loadingUnits: false, + selectedUnit: null, + selectedElement: null, + modifiedTitle: '', + modifiedColor: '', + modifiedDescription: '', + requirements: [], + text: { + sourceSelf: this.$gettext('Diese Veranstaltung'), + sourceCourses: this.$gettext('Veranstaltung'), + sourceUsers: this.$gettext('Arbeitsplatz'), + source: this.$gettext('Quelle'), + unit: this.$gettext('Lernmaterial'), + element: this.$gettext('Seite'), + }, + } + }, + computed: { + ...mapGetters({ + userId: 'userId', + coursewareUnits: 'courseware-units/all', + structuralElementById: 'courseware-structural-elements/byId', + context: 'context', + childrenById: 'courseware-structure/children', + currentElement: 'currentElement' + }), + colors() { + return this.mixinColors.filter(color => color.darkmode); + }, + inCourseContext() { + return this.context.type === 'courses'; + }, + units() { + let units = this.coursewareUnits.filter(unit => unit.relationships.range.data.id === this.selectedRange); + units.forEach(unit => { + unit.element = this.getUnitElement(unit); + }); + + return units; + }, + selectedUnitId() { + return this.selectedUnit?.id; + }, + selectedUnitRootId() { + return this.selectedUnit?.relationships?.['structural-element']?.data?.id; + }, + selectedElementTitle() { + return this.selectedElement?.attributes?.title; + }, + selectedElementParent() { + let parentData = this.selectedElement?.relationships?.parent?.data; + if (parentData){ + return this.structuralElementById({id: parentData.id}); + } + + return null; + }, + selectedElementParentTitle() { + if (this.selectedElementParent) { + return this.selectedElementParent.attributes.title; + } + + return ''; + }, + children() { + if (!this.selectedElement) { + return []; + } + + return this.childrenById(this.selectedElement.id) + .map((id) => this.structuralElementById({ id })) + .filter(Boolean); + }, + }, + mounted() { + this.initWizardData(); + }, + methods: { + ...mapActions({ + showElementCopyDialog: 'showElementCopyDialog', + loadCourseUnits: 'loadCourseUnits', + loadUserUnits: 'loadUserUnits', + loadUsersCourses: 'loadUsersCourses', + loadStructuralElement: 'courseware-structural-elements/loadById', + copyStructuralElement: 'copyStructuralElement', + companionError: 'companionError', + companionSuccess: 'companionSuccess', + }), + initWizardData() { + this.source = this.inCourseContext ? 'self' : 'users'; + this.selectedRange = ''; + this.selectedUnit = null; + }, + getUnitElement(unit) { + return this.structuralElementById({id: unit.relationships['structural-element'].data.id}); + }, + async updateCourses() { + this.loadingCourses = true; + this.courses = await this.loadUsersCourses({ userId: this.userId, withCourseware: true }); + this.loadingCourses = false; + }, + async updateCourseUnits(cid) { + this.loadingUnits = true; + await this.loadCourseUnits(cid); + this.loadingUnits = false; + }, + async updateUserUnits() { + this.loadingUnits = true; + await this.loadUserUnits(this.userId); + this.loadingUnits = false; + }, + selectElement(id) { + this.selectedElement = this.structuralElementById({id: id}); + this.loadStructuralElement({id: id, options: {include: 'children'}}); + }, + setElementData() { + this.modifiedTitle = this.selectedElement.attributes.title; + this.modifiedColor = this.selectedElement.attributes.payload.color; + this.modifiedDescription = this.selectedElement.attributes.payload.description; + }, + resetElementData() { + this.modifiedTitle = ''; + this.modifiedColor = ''; + this.modifiedDescription = ''; + }, + copyElement() { + let view = this; + this.copyStructuralElement({ + parentId: this.currentElement, + elementId: this.selectedElement.id, + migrate: false, + modifications: { + title: view.modifiedTitle, + color: view.modifiedColor, + description: view.modifiedDescription + } + }) + .then( () => { + view.companionSuccess({ + info: view.$gettextInterpolate( + view.$gettext('Die Seite %{ pageTitle } wurde erfolgreich kopiert.'), + {pageTitle: view.selectedElementTitle} + ) + }); + }) + .catch(error => { + view.companionError({ + info: view.$gettextInterpolate( + view.$gettext('Die Seite %{ pageTitle } konnte nicht kopiert werden.'), + {pageTitle: view.selectedElementTitle} + ) + }); + }) + .finally(() => { + view.showElementCopyDialog(false); + }); + }, + validateSelection() { + this.requirements = []; + if (this.selectedRange === '') { + this.requirements.push({slot: this.wizardSlots[0], text: this.text.source }); + } + if (this.selectedUnit === null) { + this.requirements.push({slot: this.wizardSlots[1], text: this.text.unit }); + } + if (this.selectedUnit === null) { + this.requirements.push({slot: this.wizardSlots[2], text: this.text.element }); + } + } + }, + watch: { + selectedElement(newElement) { + this.validateSelection(); + if (newElement !== null) { + this.wizardSlots[2].valid = true; + this.setElementData(); + } else { + this.resetElementData(); + this.wizardSlots[2].valid = false; + } + + }, + async selectedUnit(newUnit) { + this.validateSelection(); + if (newUnit !== null) { + this.wizardSlots[1].valid = true; + await this.loadStructuralElement({id: this.selectedUnitRootId, options: {include: 'children'}}); + this.selectedElement = this.structuralElementById({id: this.selectedUnitRootId}); + } else { + this.wizardSlots[1].valid = false; + } + + }, + selectedRange(newRid) { + this.validateSelection(); + this.selectedUnit = null; + if (newRid !== '') { + this.wizardSlots[0].valid = true; + if (this.source === 'courses' || this.source === 'self') { + this.updateCourseUnits(newRid); + } + if (this.source === 'users') { + this.loadUserUnits(newRid); + } + } else { + this.wizardSlots[0].valid = false; + } + }, + source(newSource) { + switch (newSource) { + case 'self': + this.selectedRange = this.context.id; + break; + case 'courses': + this.selectedRange = ''; + this.updateCourses(); + break; + case 'users': + this.selectedRange = this.userId; + break; + } + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareStructuralElementDialogImport.vue b/resources/vue/components/courseware/CoursewareStructuralElementDialogImport.vue new file mode 100644 index 0000000000000000000000000000000000000000..6c6bb81754452acda649c3c0f4848b5ff8f6fa7b --- /dev/null +++ b/resources/vue/components/courseware/CoursewareStructuralElementDialogImport.vue @@ -0,0 +1,198 @@ +<template> + <studip-dialog + :title="$gettext('Lerninhalte importieren')" + :confirmText="$gettext('Importieren')" + :confirmDisabled="importRunning || importAborted" + :closeText="importRunning || importAborted ? $gettext('Schließen') : $gettext('Abbrechen')" + height="420" + @close="showElementImportDialog(false)" + @confirm="importCoursewareArchiv" + > + <template v-slot:dialogContent> + <form v-if="!importRunning && !importAborted" class="default" @submit.prevent=""> + <label> + {{$gettext('Importdatei')}} + <input class="cw-file-input" ref="importFile" type="file" accept=".zip" @change="setImport" /> + </label> + <label> + {{$gettext('Importverhalten')}} + <select v-model="importBehavior"> + <option value="default">{{$gettext('Inhalte anhängen')}}</option> + <option value="migrate">{{$gettext('Inhalte zusammenführen')}}</option> + </select> + </label> + </form> + <div role="status" aria-live="polite"> + <courseware-companion-box + v-show="importDone && importErrors.length === 0" + :msgCompanion="$gettext('Import erfolgreich!')" + mood="special" + /> + <courseware-companion-box + v-show="importDone && importErrors.length > 0" + :msgCompanion="$gettext('Import abgeschlossen. Es sind Fehler aufgetreten!')" + mood="unsure" + /> + <courseware-companion-box + v-show="!importDone && importRunning" + :msgCompanion="$gettext('Import läuft. Bitte schließen Sie den Dialog nicht bis der Import abgeschlossen wurde.')" + mood="pointing" + /> + <courseware-companion-box + v-show="importAborted" + :msgCompanion="$gettext('Import abgebrochen. Es sind Fehler aufgetreten!')" + mood="sad" + /> + </div> + <form v-if="!importDone && importRunning" class="default" @submit.prevent=""> + <fieldset> + <div v-if="!fileImportDone" class="cw-import-zip"> + <header>{{$gettext('Importiere Dateien')}}:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: importFilesProgress + '%'}" :aria-valuenow="importFilesProgress" aria-valuemin="0" aria-valuemax="100">{{ importFilesProgress }}%</div> + </div> + {{ importFilesState }} + </div> + <div v-if="fileImportDone" class="cw-import-zip"> + <header>{{$gettext('Importiere Elemente')}}:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: importStructuresProgress + '%'}" :aria-valuenow="importStructuresProgress" aria-valuemin="0" aria-valuemax="100">{{ importStructuresProgress }}%</div> + </div> + {{ importStructuresState }} + </div> + </fieldset> + <fieldset v-show="importErrors.length > 0"> + <legend>{{$gettext('Fehlermeldungen')}}</legend> + <ul> + <li v-for="(error, index) in importErrors" :key="index"> {{error}} </li> + </ul> + </fieldset> + </form> + </template> + </studip-dialog> +</template> +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import CoursewareImport from '@/vue/mixins/courseware/import.js'; + +import { mapActions, mapGetters } from 'vuex' +import JSZip from 'jszip'; + +export default { + name: 'courseware-structural-element-dialog-import', + components: { + CoursewareCompanionBox, + }, + mixins: [CoursewareImport], + props: { + colors: Array + }, + data() { + return { + importBehavior: 'default', + importRunning: false, + importZipFile: null, + zip: null, + importAborted: false, + } + }, + computed: { + ...mapGetters({ + currentElement: 'currentElement', + importFilesState: 'importFilesState', + importFilesProgress: 'importFilesProgress', + importStructuresState: 'importStructuresState', + importStructuresProgress: 'importStructuresProgress', + importErrors: 'importErrors', + }), + fileImportDone() { + return this.importFilesProgress === 100; + }, + importDone() { + return (this.importFilesProgress === 100 && this.importStructuresProgress === 100); + } + }, + methods: { + ...mapActions({ + showElementImportDialog: 'showElementImportDialog', + loadCoursewareStructure: 'courseware-structure/load', + setImportFilesProgress: 'setImportFilesProgress', + setImportStructuresProgress: 'setImportStructuresProgress', + setImportErrors: 'setImportErrors', + }), + setImport(event) { + this.importZipFile = event.target.files[0]; + this.setImportFilesProgress(0); + this.setImportStructuresProgress(0); + this.setImportErrors([]); + }, + async importCoursewareArchiv() { + this.importAborted = false; + if (this.importZipFile === null) { + return false; + } + + this.importRunning = true; + try { + this.zip = await JSZip.loadAsync(this.importZipFile); + } catch(error) { + this.setImportErrors([this.$gettext('Die gewählte Datei ist kein Archiv oder das Archiv ist beschädigt.')]); + this.importRunning = false; + this.importAborted = true; + return; + } + let errors = []; + let missingFiles = false; + if (this.zip.file('courseware.json') === null) { + errors.push(this.$gettext('Das Archiv enthält keine courseware.json Datei.')); + missingFiles = true; + } + if (this.zip.file('files.json') === null) { + errors.push(this.$gettext('Das Archiv enthält keine files.json Datei.')); + missingFiles = true; + } + if (this.zip.file('data.xml') !== null) { + errors.push(this.$gettext( + 'Das Archiv enthält eine data.xml Datei. Möglicherweise handelt es sich um einen Export aus dem Courseware-Plugin. Diese Archive sind nicht kompatibel mit dieser Courseware.' + )); + } + if (missingFiles) { + this.setImportErrors(errors); + this.importRunning = false; + this.importAborted = true; + return; + } + + const data = await this.zip.file('courseware.json').async('string'); + let courseware = null; + const data_files = await this.zip.file('files.json').async('string'); + let files = null; + let jsonErrors = false; + try { + courseware = JSON.parse(data); + } catch (error) { + jsonErrors = true; + errors.push(this.$gettext('Die Beschreibung der Courseware-Inhalte ist nicht valide.')); + errors.push(error); + } + try { + files = JSON.parse(data_files); + } catch (error) { + jsonErrors = true; + errors.push(this.$gettext('Die Beschreibung der Dateien ist nicht valide.')); + errors.push(error); + } + if (jsonErrors) { + this.setImportErrors(errors); + this.importRunning = false; + this.importAborted = true; + return; + } + + await this.loadCoursewareStructure(); + + await this.importCourseware(courseware, this.currentElement, files, this.importBehavior, null); + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareStructuralElementDialogLink.vue b/resources/vue/components/courseware/CoursewareStructuralElementDialogLink.vue new file mode 100644 index 0000000000000000000000000000000000000000..ee9fd4bcfe50ddc89272da06b28ddc37e578cf68 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareStructuralElementDialogLink.vue @@ -0,0 +1,264 @@ +<template> + <studip-wizard-dialog + :title="$gettext('Seite verknüpfen')" + :confirmText="$gettext('Verknüpfen')" + :closeText="$gettext('Abbrechen')" + :lastRequiredSlotId="2" + :requirements="requirements" + :slots="wizardSlots" + @close="showElementLinkDialog(false)" + @confirm="linkElement" + > + <template v-slot:unit> + <form v-if="!loadingUnits" class="default" @submit.prevent=""> + <fieldset v-if="hasUnits" class="radiobutton-set"> + <template v-for="unit in units"> + <input + :id="'cw-element-link-unit-' + unit.id" + type="radio" + :checked="unit.id === selectedUnitId" + :value="unit.id" + :key="'radio-' + unit.id" + :aria-description="unit.element.attributes.title" + /> + <label @click="selectedUnit = unit" :key="'label-' + unit.id" :for="'cw-element-link-unit-' + unit.id"> + <div class="icon"><studip-icon shape="courseware" size="32"/></div> + <div class="text">{{ unit.element.attributes.title }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </template> + </fieldset> + <courseware-companion-box + v-else + mood="sad" + :msgCompanion="$gettext('Es konnte leider kein Lernmaterial gefunden werden.')" + /> + </form> + <studip-progress-indicator + v-else + :description="$gettext('Lade Lernmaterialien…')" + /> + </template> + <template v-slot:element> + <form class="default" @submit.prevent=""> + <template v-if="selectedUnit"> + <fieldset class="radiobutton-set"> + <input id="cw-element-link-element" type="radio" checked :aria-description="selectedElementTitle"/> + <label for="cw-element-link-element" @click="e => e.preventDefault()"> + <div class="icon"><studip-icon shape="content2" size="32"/></div> + <div class="text">{{ selectedElementTitle }}</div> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </fieldset> + <button + v-if="selectedElementParent" + class="button" + @click="selectElement(selectedElementParent.id)" + > + {{ $gettextInterpolate( + $gettext('zurück zu %{ parentTitle }'), + { parentTitle: selectedElementParentTitle } + ) }} + </button> + <fieldset> + <legend>{{ $gettext('Unterseiten') }}</legend> + <ul class="cw-element-selector-list"> + <li + v-for="child in children" + :key="child.id" + > + <button + class="cw-element-selector-item" + @click="selectElement(child.id)" + > + {{ child.attributes.title }} + </button> + + </li> + <li v-if="children.length === 0"> + {{ $gettext('Es wurden keine Unterseiten gefunden.') }} + </li> + </ul> + </fieldset> + </template> + <courseware-companion-box + v-if="!selectedUnit" + mood="pointing" + :msgCompanion="$gettext('Bitte wählen Sie zuerst das Lernmaterial aus.')" + /> + </form> + </template> + </studip-wizard-dialog> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import StudipWizardDialog from './../StudipWizardDialog.vue'; +import StudipProgressIndicator from '../StudipProgressIndicator.vue'; + +import { mapActions, mapGetters } from 'vuex' + +export default { + name: 'courseware-structural-element-dialog-link', + components: { + CoursewareCompanionBox, + StudipWizardDialog, + StudipProgressIndicator + }, + data() { + return { + wizardSlots: [ + {id: 1, valid: false, name: 'unit', title: this.$gettext('Lernmaterial'), icon: 'courseware', + description: this.$gettext('Wählen Sie das Lernmaterial aus, in dem sich der zu verknüpfende Lerninhalt befindet.')}, + {id: 2, valid: false, name: 'element', title: this.$gettext('Seite'), icon: 'content2', + description: this.$gettext('Wählen Sie die zu verknüpfende Seite aus. Vorausgewählt ist die oberste Seite des ausgewählten Lernmaterials. Unterseiten erreichen Sie über die Schaltflächen im Bereich "Unterseiten". Sie können über die "zurück zu" Schaltfläche das übergeordnete Element anwählen. Die ausgewählte Seite ist mit einem Kontrollhaken markiert.')}, ], + loadingUnits: false, + selectedUnit: null, + selectedElement: null, + requirements: [], + text: { + + } + } + }, + computed: { + ...mapGetters({ + userId: 'userId', + coursewareUnits: 'courseware-units/all', + structuralElementById: 'courseware-structural-elements/byId', + context: 'context', + childrenById: 'courseware-structure/children', + currentElement: 'currentElement' + }), + units() { + let units = this.coursewareUnits.filter(unit => unit.relationships.range.data.id === this.userId); + units.forEach(unit => { + unit.element = this.getUnitElement(unit); + }); + + return units; + }, + hasUnits() { + return this.units.length !== 0; + }, + selectedUnitId() { + return this.selectedUnit?.id; + }, + selectedUnitRootId() { + return this.selectedUnit?.relationships?.['structural-element']?.data?.id; + }, + selectedElementTitle() { + return this.selectedElement?.attributes?.title; + }, + selectedElementParent() { + let parentData = this.selectedElement?.relationships?.parent?.data; + if (parentData){ + return this.structuralElementById({id: parentData.id}); + } + + return null; + }, + selectedElementParentTitle() { + if (this.selectedElementParent) { + return this.selectedElementParent.attributes.title; + } + + return ''; + }, + children() { + if (!this.selectedElement) { + return []; + } + + return this.childrenById(this.selectedElement.id) + .map((id) => this.structuralElementById({ id })) + .filter(Boolean); + }, + }, + mounted() { + this.initWizardData(); + this.updateUserUnits(); + }, + methods: { + ...mapActions({ + showElementLinkDialog: 'showElementLinkDialog', + loadUserUnits: 'loadUserUnits', + loadUsersCourses: 'loadUsersCourses', + loadStructuralElement: 'courseware-structural-elements/loadById', + linkStructuralElement: 'linkStructuralElement', + companionError: 'companionError', + companionSuccess: 'companionSuccess', + }), + initWizardData() { + this.selectedRange = ''; + this.selectedUnit = null; + this.validateSelection(); + }, + async updateUserUnits() { + this.loadingUnits = true; + await this.loadUserUnits(this.userId); + this.loadingUnits = false; + }, + getUnitElement(unit) { + return this.structuralElementById({id: unit.relationships['structural-element'].data.id}); + }, + linkElement() { + let view = this; + this.linkStructuralElement({ + parentId: this.currentElement, + elementId: this.selectedElement.id, + }) + .then( () => { + view.companionSuccess({ + info: view.$gettextInterpolate( + view.$gettext('Die Seite %{ pageTitle } wurde erfolgreich verknüpft.'), + { pageTitle: view.selectedElementTitle } + ) + }); + }) + .catch( () => { + view.companionError({ + info: view.$gettextInterpolate( + view.$gettext('Die Seite %{ pageTitle } konnte nicht verknüpft werden.'), + { pageTitle: view.selectedElementTitle } + ) + }); + }) + .finally(() => { + view.showElementLinkDialog(false); + }); + }, + selectElement(id) { + this.selectedElement = this.structuralElementById({id: id}); + this.loadStructuralElement({id: id, options: {include: 'children'}}); + }, + validateSelection() { + this.requirements = []; + if (this.selectedUnit === null) { + this.requirements.push({slot: this.wizardSlots[0], text: this.$gettext('Lernmaterial') }); + } + } + }, + watch: { + selectedElement(newElement) { + this.validateSelection(); + if (newElement !== null) { + this.wizardSlots[1].valid = true; + } else { + this.wizardSlots[1].valid = false; + } + }, + async selectedUnit(newUnit) { + this.validateSelection(); + if (newUnit !== null) { + this.wizardSlots[0].valid = true; + await this.loadStructuralElement({id: this.selectedUnitRootId, options: {include: 'children'}}); + this.selectedElement = this.structuralElementById({id: this.selectedUnitRootId}); + } else { + this.wizardSlots[0].valid = false; + } + }, + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue b/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue index cc079eada2d862a951a42a822079e0756ea64868..249b13aa2a26977eb19e185701dbd32788ccac5a 100644 --- a/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue @@ -26,7 +26,7 @@ <script> import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareTalkBubble from './CoursewareTalkBubble.vue'; -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-structural-element-feedback', @@ -68,6 +68,10 @@ export default { } }, methods: { + ...mapActions({ + createFeedback: 'courseware-structural-element-feedback/create', + loadRelatedFeedback: 'courseware-structural-element-feedback/loadRelated', + }), buildPayload(feedback) { const { id, type } = feedback; const user = this.getRelatedUser({ parent: { id, type }, relationship: 'user' }); @@ -86,7 +90,7 @@ export default { type: this.structuralElement.type, id: this.structuralElement.id, }; - await this.$store.dispatch('courseware-structural-element-feedback/loadRelated', { + await this.loadRelatedFeedback({ parent, relationship: 'feedback', options: { @@ -108,7 +112,7 @@ export default { } }, }; - await this.$store.dispatch('courseware-structural-element-feedback/create', data, { root: true }); + await this.createFeedback( data, { root: true }); this.feedbackText = ''; this.loadFeedback(); } diff --git a/resources/vue/components/courseware/CoursewareTableOfContentsBlock.vue b/resources/vue/components/courseware/CoursewareTableOfContentsBlock.vue index 1a5828081096b2cee1bd15424361ca0ef53f9f29..9e6c7c3fcf6eb9aeb5065d82b76e54e580b040d9 100644 --- a/resources/vue/components/courseware/CoursewareTableOfContentsBlock.vue +++ b/resources/vue/components/courseware/CoursewareTableOfContentsBlock.vue @@ -157,7 +157,7 @@ export default { taskId: taskId, }); } catch(error) { - console.debug(error); + // nothing to do here } } }); diff --git a/resources/vue/components/courseware/CoursewareTasksActionWidget.vue b/resources/vue/components/courseware/CoursewareTasksActionWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..2f3ca3de0b8ebf952f0c4f4df9222edbaa55e0c3 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareTasksActionWidget.vue @@ -0,0 +1,31 @@ +<template> + <sidebar-widget :title="$gettext('Aktionen')"> + <template #content> + <ul class="widget-list widget-links cw-action-widget"> + <li class="cw-action-widget-add"> + <button @click="setShowTasksDistributeDialog(true)"> + {{ $gettext('Aufgabe verteilen') }} + </button> + </li> + </ul> + </template> + </sidebar-widget> +</template> + +<script> +import SidebarWidget from '../SidebarWidget.vue'; + +import { mapActions } from 'vuex'; + +export default { + name: 'courseware-tasks-action-widget', + components: { + SidebarWidget, + }, + methods: { + ...mapActions({ + setShowTasksDistributeDialog: 'setShowTasksDistributeDialog', + }), + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue b/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue new file mode 100644 index 0000000000000000000000000000000000000000..9de8f3d540a291b0c4171e7cd105e2ff06e2fcf0 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue @@ -0,0 +1,633 @@ +<template> + <studip-wizard-dialog + :title="$gettext('Aufgabe verteilen')" + :confirmText="$gettext('Verteilen')" + :closeText="$gettext('Abbrechen')" + :lastRequiredSlotId="6" + :requirements="requirements" + :slots="wizardSlots" + @close="setShowTasksDistributeDialog(false)" + @confirm="distributeTask" + > + <template v-slot:sourceunit> + <form class="default" @submit.prevent=""> + <fieldset v-if="sourceUnits.length !== 0" class="radiobutton-set"> + <template v-for="unit in sourceUnits"> + <input + :id="'cw-task-dist-source-unit' + unit.id" + type="radio" + v-model="selectedSourceUnit" + :checked="unit.id === selectedSourceUnitId" + :value="unit" + :key="'radio-' + unit.id" + :aria-description="unit.element.attributes.title" + /> + <label @click="selectedSourceUnit = unit" :key="'label-' + unit.id" :for="'cw-task-dist-source-unit' + unit.id"> + <div class="icon"><studip-icon shape="courseware" size="32"/></div> + <div class="text">{{ unit.element.attributes.title }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </template> + </fieldset> + <courseware-companion-box + v-else + mood="sad" + :msgCompanion="$gettext('Es stehen keine Lernmaterialien zur Verfügung.')" + /> + </form> + </template> + <template v-slot:task> + <form v-if="selectedSourceUnit" class="default" @submit.prevent=""> + <fieldset class="radiobutton-set"> + <input id="cw-task-dist-task" type="radio" :checked="selectedTaskIsTask" :aria-description="selectedTaskTitle"/> + <label for="cw-task-dist-task" @click="e => e.preventDefault()"> + <div class="icon"><studip-icon shape="content2" size="32"/></div> + <div class="text">{{ selectedTaskTitle }}</div> + <studip-icon v-if="selectedTaskIsTask" shape="check-circle" size="24" class="check" /> + <studip-icon v-else shape="decline-circle" size="24" class="unchecked" /> + </label> + </fieldset> + <button + v-if="selectedTaskParent" + class="button" + @click="selectTask(selectedTaskParent.id)" + > + {{ $gettext('zurück zur übergeordneten Seite') }} + </button> + <fieldset> + <legend>{{ $gettext('Unterseiten') }}</legend> + <ul class="cw-element-selector-list"> + <li + v-for="child in taskChildren" + :key="child.id" + > + <button + class="cw-element-selector-item" + @click="selectTask(child.id)" + > + {{ child.attributes.title }} + </button> + </li> + <li v-if="taskChildren.length === 0"> + {{ $gettext('Es wurden keine Unterseiten gefunden.') }} + </li> + </ul> + </fieldset> + </form> + <courseware-companion-box + v-else + mood="pointing" + :msgCompanion="$gettext('Bitte wählen Sie ein Lernmaterial aus.')" + /> + </template> + <template v-slot:tasksettings> + <form v-if="selectedTaskIsTask" class="default" @submit.prevent=""> + <label> + <span>{{ $gettext('Aufgabentitel') }}</span><span aria-hidden="true" class="wizard-required">*</span> + <input type="text" v-model="taskTitle" required/> + </label> + <label> + <span>{{ $gettext('Abgabefrist') }}</span><span aria-hidden="true" class="wizard-required">*</span> + <input type="date" v-model="submissionDate" /> + </label> + <label> + {{ $gettext('Inhalte ergänzen') }} + <select class="size-s" v-model="solverMayAddBlocks"> + <option value="true">{{ $gettext('ja') }}</option> + <option value="false">{{ $gettext('nein') }}</option> + </select> + </label> + </form> + <courseware-companion-box + v-else + mood="pointing" + :msgCompanion="$gettext('Bitte wählen Sie eine Aufgabenvorlage aus.')" + /> + </template> + <template v-slot:targetunit> + <form v-if="selectedTaskIsTask" class="default" @submit.prevent=""> + <fieldset v-if="targetUnits.length !== 0" class="radiobutton-set"> + <template v-for="unit in targetUnits"> + <input + :id="'cw-task-dist-target-unit' + unit.id" + type="radio" + v-model="selectedTargetUnit" + :checked="unit.id === selectedTargetUnitId" + :value="unit" + :key="'radio-' + unit.id" + :aria-description="unit.element.attributes.title" + /> + <label @click="selectedTargetUnit = unit" :key="'label-' + unit.id" :for="'cw-task-dist-target-unit' + unit.id"> + <div class="icon"><studip-icon shape="courseware" size="32"/></div> + <div class="text">{{ unit.element.attributes.title }}</div> + <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" /> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </template> + </fieldset> + <courseware-companion-box + v-else + mood="sad" + :msgCompanion="$gettext('Es stehen keine Lernmaterialien zur Verfügung.')" + /> + </form> + <courseware-companion-box + v-else + mood="sad" + :msgCompanion="$gettext('Bitte wählen Sie eine Aufgabe aus.')" + /> + </template> + <template v-slot:targetelement> + <form v-if="selectedTargetUnit && selectedTaskIsTask" class="default" @submit.prevent=""> + <fieldset class="radiobutton-set"> + <input id="cw-task-dist-target-element" type="radio" checked :aria-description="selectedTargetElementTitle"/> + <label for="cw-task-dist-target-element" @click="e => e.preventDefault()"> + <div class="icon"><studip-icon shape="content2" size="32"/></div> + <div class="text">{{ selectedTargetElementTitle }}</div> + <studip-icon shape="check-circle" size="24" class="check" /> + </label> + </fieldset> + <button + v-if="selectedTargetElementParent" + class="button" + @click="selectTargetElement(selectedTargetElementParent.id)" + > + {{ $gettext('zurück zur übergeordneten Seite') }} + </button> + <fieldset> + <legend>{{ $gettext('Unterseiten') }}</legend> + <ul class="cw-element-selector-list"> + <li + v-for="child in targetChildren" + :key="child.id" + > + <button + class="cw-element-selector-item" + @click="selectTargetElement(child.id)" + > + {{ child.attributes.title }} + </button> + </li> + <li v-if="targetChildren.length === 0"> + {{ $gettext('Es wurden keine Unterseiten gefunden.') }} + </li> + </ul> + </fieldset> + </form> + <courseware-companion-box + v-if="!selectedTaskIsTask" + mood="sad" + :msgCompanion="$gettext('Bitte wählen Sie eine Aufgabe aus.')" + /> + <courseware-companion-box + v-if="!selectedTargetUnit" + mood="sad" + :msgCompanion="$gettext('Bitte wählen Sie ein Lernmaterial als Ziel aus.')" + /> + </template> + <template v-slot:solver> + <form v-if="selectedTargetElement && selectedTaskIsTask" class="default" @submit.prevent=""> + <label> + {{ $gettext('Verteilen an') }} + <select v-model="taskSolverType"> + <option value="autor">{{ $gettext('Studierende') }}</option> + <option value="group">{{ $gettext('Gruppen') }}</option> + </select> + </label> + <template v-if="taskSolverType === 'autor'" > + <courseware-companion-box + v-show="autor_members.length === 0" + :msgCompanion="$gettext('Es wurden keine Studierenden in dieser Veranstaltung gefunden.')" + mood="pointing" + /> + <table v-show="autor_members.length > 0" class="default"> + <thead> + <tr> + <th><input type="checkbox" v-model="bulkSelectAutors"/></th> + <th>{{ $gettext('Name') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="user in autor_members" :key="user.user_id"> + <td><input type="checkbox" v-model="selectedAutors" :value="user.user_id" /></td> + <td>{{ user.formattedname }}</td> + </tr> + </tbody> + </table> + </template> + <template v-if="taskSolverType === 'group'"> + <courseware-companion-box + v-show="groups.length === 0" + :msgCompanion="$gettext('Es wurden keine Gruppen in dieser Veranstaltung gefunden.')" + mood="pointing" + /> + <table v-show="groups.length > 0" class="default"> + <thead> + <tr> + <th><input type="checkbox" v-model="bulkSelectGroups"/></th> + <th>{{ $gettext('Gruppenname') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="group in groups" :key="group.id"> + <td><input type="checkbox" v-model="selectedGroups" :value="group.id" /></td> + <td>{{ group.name }}</td> + </tr> + </tbody> + </table> + </template> + </form> + <courseware-companion-box + v-if="!selectedTaskIsTask" + mood="sad" + :msgCompanion="$gettext('Bitte wählen Sie eine Aufgabe aus.')" + /> + <courseware-companion-box + v-if="!selectedTargetElement" + mood="sad" + :msgCompanion="$gettext('Bitte wählen Sie eine Seite aus.')" + /> + </template> + </studip-wizard-dialog> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import StudipWizardDialog from './../StudipWizardDialog.vue'; + +import { mapActions, mapGetters } from 'vuex' + +export default { + name: 'courseware-tasks-dialog-distribute', + components: { + CoursewareCompanionBox, + StudipWizardDialog, + }, + data() { + return { + wizardSlots: [ + { id: 1, valid: false, name: 'sourceunit', title: this.$gettext('Lernmaterial'), icon: 'courseware', + description: this.$gettext('Wählen Sie das Lernmaterial aus, in dem sich die Aufgabenvorlage befindet. Es sind nur Lernmaterialien aus Ihrem Arbeitsplatz aufgeführt.') }, + { id: 2, valid: false, name: 'task', title: this.$gettext('Aufgabenvorlage'), icon: 'category-task', + description: this.$gettext('Wählen Sie die zu verteilende Aufgabenvorlage aus. Vorausgewählt ist die oberste Seite des ausgewählten Lernmaterials. Unterseiten erreichen Sie über die Schaltflächen im Bereich "Unterseiten". Sie können über die "zurück zu" Schaltfläche das übergeordnete Element anwählen. Die ausgewählte Aufgabenvorlage ist mit einem Kontrollhaken markiert. Nur Seiten der Kategorie "Aufgabenvorlage" können verteilt werden.') }, + { id: 3, valid: false, name: 'tasksettings', title: this.$gettext('Aufgabeneinstellungen'), icon: 'settings', + description: this.$gettext('Wählen Sie hier die Einstellungen der Aufgabe. Es muss ein Aufgabentitel und eine Abgabenfrist gesetzt werden.') }, + { id: 4, valid: false, name: 'targetunit', title: this.$gettext('Ziel-Lernmaterial'), icon: 'courseware', + description: this.$gettext('Wählen Sie hier das Lernmaterial aus, in das die Aufgabe verteilt werden soll. Zum Bearbeiten der Aufgabe müssen Lernende Zugriff auf das Lernmaterial haben. Prüfen Sie gegebenenfalls die Leserechte und die Sichtbarkeit.') }, + { id: 5, valid: false, name: 'targetelement', title: this.$gettext('Zielseite'), icon: 'content2', + description: this.$gettext('Wählen Sie hier die Seite aus unterhalb der die Aufgabe verteilt werden soll. Zum bearbeiten der Aufgabe müssen Lernende Zugriff auf die Seite haben. Prüfen Sie ggf. die Leserechte und die Sichtbarkeit.') }, + { id: 6, valid: false, name: 'solver', title: this.$gettext('Aufgabe zuweisen'), icon: 'group3', + description: this.$gettext('Wählen Sie hier aus, an wen Sie die Aufgaben verteilen möchten. Aufgaben können entweder an Gruppen oder einzelne Teilnehmende verteilt werden. Über die Checkbox im Titel der Tabelle können Sie alles aus- bzw. abwählen.') }, + ], + selectedSourceUnit: null, + taskTitle: '', + submissionDate: '', + solverMayAddBlocks: true, + selectedTask: null, + selectedTargetUnit: null, + selectedTargetElement: null, + taskSolverType: 'autor', + selectedAutors: [], + bulkSelectAutors: false, + selectedGroups: [], + bulkSelectGroups: false, + requirements: [], + } + }, + computed: { + ...mapGetters({ + userId: 'userId', + coursewareUnits: 'courseware-units/all', + structuralElementById: 'courseware-structural-elements/byId', + structuralElements: 'courseware-structural-elements/all', + context: 'context', + currentElement: 'currentElement', + relatedCourseMemberships: 'course-memberships/related', + relatedCourseStatusGroups: 'status-groups/related', + relatedUser: 'users/related', + }), + selectedSourceUnitId() { + return this.selectedSourceUnit?.id; + }, + selectedSourceUnitRootId() { + return this.selectedSourceUnit?.relationships?.['structural-element']?.data?.id; + }, + sourceUnits() { + let units = this.coursewareUnits.filter(unit => unit.relationships.range.data.id === this.userId); + units.forEach(unit => { + unit.element = this.getUnitElement(unit); + }); + + return units; + }, + selectedTargetUnitId() { + return this.selectedTargetUnit?.id; + }, + selectedTargetUnitRootId() { + return this.selectedTargetUnit?.relationships?.['structural-element']?.data?.id; + }, + targetUnits() { + let units = this.coursewareUnits.filter(unit => unit.relationships.range.data.id === this.context.id); + units.forEach(unit => { + unit.element = this.getUnitElement(unit); + }); + + return units; + }, + selectedTaskIsTask() { + return this.selectedTask?.attributes?.purpose === 'template'; + }, + selectedTaskTitle() { + return this.selectedTask?.attributes?.title; + }, + selectedTaskParent() { + let parentData = this.selectedTask?.relationships?.parent?.data; + if (parentData){ + return this.structuralElementById({id: parentData.id}); + } + + return null; + }, + selectedTaskParentTitle() { + if (this.selectedTaskParent) { + return this.selectedTaskParent.attributes.title; + } + + return ''; + }, + taskChildren() { + let children = []; + if (this.selectedTask) { + children = this.structuralElements.filter( + element => element.relationships.parent?.data?.id === this.selectedTask.id + ); + } + + return children; + }, + selectedTargetElementTitle() { + return this.selectedTargetElement?.attributes?.title; + }, + selectedTargetElementParent() { + let parentData = this.selectedTargetElement?.relationships?.parent?.data; + if (parentData){ + return this.structuralElementById({id: parentData.id}); + } + + return null; + }, + selectedTargetElementParentTitle() { + if (this.selectedTargetElementParent) { + return this.selectedTargetElementParent.attributes.title; + } + + return ''; + }, + targetChildren() { + let children = []; + if (this.selectedTargetElement) { + children = this.structuralElements.filter( + element => element.relationships.parent?.data?.id === this.selectedTargetElement.id + ); + } + + return children; + }, + users() { + const parent = { type: 'courses', id: this.context.id }; + const relationship = 'memberships'; + const memberships = this.relatedCourseMemberships({ parent, relationship }); + + return ( + memberships?.map((membership) => { + const parent = { type: membership.type, id: membership.id }; + const member = this.relatedUser({ parent, relationship: 'user' }); + + return { + user_id: member.id, + formattedname: member.attributes['formatted-name'], + username: member.attributes['username'], + perm: membership.attributes['permission'], + }; + }) ?? [] + ); + }, + groups() { + const parent = { type: 'courses', id: this.context.id }; + const relationship = 'status-groups'; + const statusGroups = this.relatedCourseStatusGroups({ parent, relationship }); + + return ( + statusGroups?.map((statusGroup) => { + return { + id: statusGroup.id, + name: statusGroup.attributes['name'], + }; + }) ?? [] + ); + }, + autor_members() { + if (Object.keys(this.users).length === 0 && this.users.constructor === Object) { + return []; + } + + let members = this.users + .filter(function (user) { + return user.perm === 'autor'; + }) + .map((obj) => ({ ...obj, active: false })); + + return members; + }, + }, + mounted() { + this.initWizardData(); + const parent = { type: 'courses', id: this.context.id }; + this.loadCourseMemberships({ parent, relationship: 'memberships', options: { include: 'user', 'page[offset]': 0, 'page[limit]': 10000, 'filter[permission]': 'autor' } }); + this.loadCourseStatusGroups({ parent, relationship: 'status-groups' }); + }, + methods: { + ...mapActions({ + setShowTasksDistributeDialog: 'setShowTasksDistributeDialog', + loadCourseUnits: 'loadCourseUnits', + loadUserUnits: 'loadUserUnits', + loadUsersCourses: 'loadUsersCourses', + loadStructuralElement: 'courseware-structural-elements/loadById', + copyStructuralElement: 'copyStructuralElement', + companionError: 'companionError', + companionSuccess: 'companionSuccess', + loadCourseMemberships: 'course-memberships/loadRelated', + loadCourseStatusGroups: 'status-groups/loadRelated', + createTaskGroup: 'createTaskGroup', + }), + async initWizardData() { + this.loadUserUnits(this.userId); + this.loadCourseUnits(this.context.id); + this.validate(); + }, + getUnitElement(unit) { + return this.structuralElementById({id: unit.relationships['structural-element'].data.id}); + }, + selectTask(id) { + this.selectedTask = this.structuralElementById({id: id}); + this.loadStructuralElement({id: id, options: {include: 'children'}}); + }, + selectTargetElement(id) { + this.selectedTargetElement = this.structuralElementById({id: id}); + this.loadStructuralElement({id: id, options: {include: 'children'}}); + }, + async distributeTask() { + this.setShowTasksDistributeDialog(false); + const taskGroup = { + attributes: { + title: this.taskTitle, + 'submission-date': new Date(this.submissionDate).toISOString(), + 'solver-may-add-blocks': this.solverMayAddBlocks, + }, + relationships: { + solvers: { + data: [], + }, + target: { + data: { + id: this.selectedTargetElement.id, + type: 'courseware-structural-elements', + }, + }, + 'task-template': { + data: { + id: this.selectedTask.id, + type: 'courseware-structural-elements', + }, + }, + }, + }; + + let solvers; + if (this.taskSolverType === 'autor') { + solvers = this.selectedAutors.map((id) => ({ type: 'users', id })); + } + if (this.taskSolverType === 'group') { + solvers = this.selectedGroups.map((id) => ({ type: 'status-groups', id })); + } + taskGroup.relationships.solvers.data = solvers; + + await this.createTaskGroup({ taskGroup }); + this.companionSuccess({ info: this.$gettext('Aufgaben wurden verteilt.') }); + + }, + validateSolvers() { + if ( + (this.selectedAutors.length > 0 && this.taskSolverType === 'autor') || + (this.selectedGroups.length > 0 && this.taskSolverType === 'group') + ) { + this.wizardSlots[5].valid = true; + } else { + this.wizardSlots[5].valid = false; + } + + return this.wizardSlots[5].valid; + }, + validateTaskSettings() { + if (this.taskTitle !== '' && this.submissionDate !== '') { + this.wizardSlots[2].valid = true; + } else { + this.wizardSlots[2].valid = false; + } + + return this.wizardSlots[2].valid; + }, + validate() { + this.requirements = []; + if (this.selectedSourceUnit === null) { + this.requirements.push({ slot: this.wizardSlots[0], text: this.$gettext('Lernmaterial') }); + } + if (!this.selectedTaskIsTask) { + this.requirements.push({ slot: this.wizardSlots[1], text: this.$gettext('Aufgabenvorlage') }); + } + if (!this.validateTaskSettings()) { + this.requirements.push({ slot: this.wizardSlots[2], text: this.$gettext('Aufgabeneinstellungen') }); + } + if (this.selectedTargetUnit === null) { + this.requirements.push({ slot: this.wizardSlots[3], text: this.$gettext(' Ziel-Lernmaterial') }); + } + if (this.selectedTargetElement === null) { + this.requirements.push({ slot: this.wizardSlots[4], text: this.$gettext(' Zielseite') }); + } + if (!this.validateSolvers()) { + this.requirements.push({ slot: this.wizardSlots[5], text: this.$gettext('Aufgabe zuweisen') }); + } + } + }, + watch: { + async selectedSourceUnit(newUnit) { + this.validate(); + if (newUnit !== null) { + this.wizardSlots[0].valid = true; + await this.loadStructuralElement({id: this.selectedSourceUnitRootId, options: {include: 'children'}}); + this.selectedTask = this.structuralElementById({id: this.selectedSourceUnitRootId}); + } else { + this.wizardSlots[0].valid = false; + } + }, + selectedTask(newTask) { + this.validate(); + if (newTask !== null && this.selectedTaskIsTask) { + this.wizardSlots[1].valid = true; + } else { + this.wizardSlots[1].valid = false; + } + }, + async selectedTargetUnit(newUnit) { + this.validate(); + if (newUnit !== null) { + this.wizardSlots[3].valid = true; + await this.loadStructuralElement({id: this.selectedTargetUnitRootId, options: {include: 'children'}}); + this.selectedTargetElement = this.structuralElementById({id: this.selectedTargetUnitRootId}); + } else { + this.wizardSlots[3].valid = false; + } + }, + selectedTargetElement(newElement) { + this.validate(); + if (newElement !== null) { + this.wizardSlots[4].valid = true; + } else { + this.wizardSlots[4].valid = false; + } + }, + + taskTitle() { + this.validate(); + }, + submissionDate() { + this.validate(); + }, + + selectedAutors() { + this.validate(); + }, + selectedGroups() { + this.validate(); + }, + taskSolverType() { + this.validate(); + }, + bulkSelectAutors(newState) { + if (newState) { + this.selectedAutors = this.autor_members.map( autor => autor.user_id); + } else { + this.selectedAutors = []; + } + }, + bulkSelectGroups(newState) { + if (newState) { + this.selectedGroups = this.groups.map( group => group.id); + } else { + this.selectedGroups = []; + } + } + } +} +</script> diff --git a/resources/vue/components/courseware/CoursewareTile.vue b/resources/vue/components/courseware/CoursewareTile.vue new file mode 100644 index 0000000000000000000000000000000000000000..6976acec1d7a24b904315db5223b44dbf1a116b0 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareTile.vue @@ -0,0 +1,137 @@ +<template> + <component :is="tag" class="cw-tile" :class="[color]"> + <div + class="preview-image" + :class="[hasImage ? '' : 'default-image']" + :style="previewImageStyle" + > + <div class="overlay-text" v-if="hasImageOverlay"> + <slot name="image-overlay"></slot> + </div> + <div class="overlay-action-menu" v-if="hasImageOverlayWithActionMenu"> + <slot name="image-overlay-with-action-menu"></slot> + </div> + </div> + <component + :is="hasDescriptionLink ? 'a' : 'div'" + :href="hasDescriptionLink ? descriptionLink : ''" + :title="descriptionTitle" + class="description" + > + <header + :class="[icon ? 'description-icon-' + icon : '']" + > + {{ title }} + </header> + <div + v-if="displayProgress" + :title="progressTitle" + class="progress-wrapper" > + <progress :value="progress" max="100">{{ progress }}</progress> + </div> + <div class="description-text-wrapper"> + <p><slot name="description"></slot></p> + </div> + <footer> + <slot name="footer"></slot> + </footer> + </component> + </component> +</template> + +<script> +export default { + name: "courseware-tile", + props: { + tag: { + type: String, + default: "div", + validator: tag => { + return ["div", "li"].includes(tag); + } + }, + color: { + type: String, + default: "studip-blue", + validator: value => { + return [ + "black", + "charcoal", + "royal-purple", + "iguana-green", + "queen-blue", + "verdigris", + "mulberry", + "pumpkin", + "sunglow", + "apple-green", + "studip-blue", + "studip-lightblue", + "studip-green", + "studip-yellow", + "studip-gray", + ].includes(value); + } + }, + title: { + type: String, + default: "–" + }, + icon: { + type: String + }, + imageUrl: { + type: String + }, + displayProgress: { + type: Boolean, + default: false + }, + progress: { + type: Number, + validator: value => { + return value >= 0 && value <= 100; + } + }, + descriptionLink: { + type: String, + default: "" + }, + descriptionTitle: { + type: String, + default: '' + } + }, + computed: { + hasImage() { + return this.imageUrl !== "" && this.imageUrl !== undefined; + }, + hasImageOverlay() { + return this.$slots["image-overlay"] !== undefined; + }, + hasImageOverlayWithActionMenu() { + return this.$slots["image-overlay-with-action-menu"] !== undefined; + }, + previewImageStyle() { + if (this.hasImage) { + return { "background-image": "url(" + this.imageUrl + ")" }; + } + else { + return {}; + } + }, + progressTitle() { + return this.$gettextInterpolate(this.$gettext("Fortschritt: %{progress}%"), { progress: this.progress }); + }, + hasDescriptionLink() { + return this.descriptionLink !== ''; + } + }, + methods: { + showProgress(e) { + e.preventDefault(); + this.$emit("showProgress"); + } + }, +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareTimelineBlock.vue b/resources/vue/components/courseware/CoursewareTimelineBlock.vue index d8db9176710e1d8b7c3ed08f9481df2b2d850ad9..31a678c76e7830fc5b4c020d145077145d8fee91 100755 --- a/resources/vue/components/courseware/CoursewareTimelineBlock.vue +++ b/resources/vue/components/courseware/CoursewareTimelineBlock.vue @@ -146,12 +146,13 @@ import CoursewareTabs from './CoursewareTabs.vue'; import CoursewareTab from './CoursewareTab.vue'; import { mapActions } from 'vuex'; import { blockMixin } from './block-mixin.js'; +import colorMixin from '@/vue/mixins/courseware/colors.js'; import contentIcons from './content-icons.js'; import StudipIcon from '../StudipIcon.vue'; export default { name: 'courseware-timeline-block', - mixins: [blockMixin], + mixins: [blockMixin, colorMixin], components: { CoursewareDefaultBlock, CoursewareTabs, @@ -186,26 +187,7 @@ export default { return contentIcons; }, colors() { - const colors = [ - {name: this.$gettext('Schwarz'), class: 'black', hex: '#000000', level: 100, icon: 'black', darkmode: true}, - - {name: this.$gettext('Blau'), class: 'studip-blue', hex: '#28497c', level: 100, icon: 'blue', darkmode: true}, - {name: this.$gettext('Rot'), class: 'studip-red', hex: '#d60000', level: 100, icon: 'red', darkmode: false}, - {name: this.$gettext('Grün'), class: 'studip-green', hex: '#008512', level: 100, icon: 'green', darkmode: true}, - {name: this.$gettext('Gelb'), class: 'studip-yellow', hex: '#ffbd33', level: 100, icon: 'yellow', darkmode: false}, - {name: this.$gettext('Grau'), class: 'studip-gray', hex: '#636a71', level: 100, icon: 'grey', darkmode: true}, - - {name: this.$gettext('Holzkohle'), class: 'charcoal', hex: '#3c454e', level: 100, icon: false, darkmode: true}, - {name: this.$gettext('Königliches Purpur'), class: 'royal-purple', hex: '#8656a2', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Leguangrün'), class: 'iguana-green', hex: '#66b570', level: 60, icon: false, darkmode: true}, - {name: this.$gettext('Königin blau'), class: 'queen-blue', hex: '#536d96', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Helles Seegrün'), class: 'verdigris', hex: '#41afaa', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Maulbeere'), class: 'mulberry', hex: '#bf5796', level: 80, icon: false, darkmode: true}, - {name: this.$gettext('Kürbis'), class: 'pumpkin', hex: '#f26e00', level: 100, icon: false, darkmode: true}, - {name: this.$gettext('Apfelgrün'), class: 'apple-green', hex: '#8bbd40', level: 80, icon: false, darkmode: true}, - ]; - - return colors; + return this.mixinColors.filter(color => color.class !== 'white' && color.class !== 'studip-lightblue'); }, sortedItems() { if (this.currentSort === 'none') { diff --git a/resources/vue/components/courseware/CoursewareToolsAdmin.vue b/resources/vue/components/courseware/CoursewareToolsAdmin.vue deleted file mode 100644 index 974e6fbbf8da495b042ddc4fd68f1be72e535338..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/CoursewareToolsAdmin.vue +++ /dev/null @@ -1,272 +0,0 @@ -<template> - <div class="cw-tools cw-tools-admin"> - <form class="default" @submit.prevent=""> - <fieldset> - <legend>{{ $gettext('Allgemeine Einstellungen') }}</legend> - <label> - <span>{{ $gettext('Art der Inhaltsabfolge') }}</span> - <select class="size-s" v-model="currentProgression"> - <option value="0">{{ $gettext('Frei') }}</option> - <option value="1">{{ $gettext('Sequentiell') }}</option> - </select> - </label> - - <label> - <span>{{ $gettext('Editierberechtigung für Tutor/-innen') }}</span> - <select class="size-s" v-model="currentPermissionLevel"> - <option value="dozent">{{ $gettext('Nein') }}</option> - <option value="tutor">{{ $gettext('Ja') }}</option> - </select> - </label> - </fieldset> - <fieldset> - <legend> - {{ $gettext('Zertifikate') }} - </legend> - <label> - <input type="checkbox" name="makecert" v-model="makeCert"> - <span> - {{ $gettext('Zertifikat bei Erreichen einer Fortschrittsgrenze versenden') }} - </span> - <studip-tooltip-icon :text="$gettext('Erreicht eine Person in diesem Lernmaterial den ' + - 'hier eingestellten Fortschritt, so erhält Sie ein PDF-Zertifikat per E-Mail.')"/> - </label> - <label v-if="makeCert"> - <span> - {{ $gettext('Erforderlicher Fortschritt (in Prozent), um ein Zertifikat zu erhalten') }} - </span> - <input type="number" min="1" max="100" name="threshold" v-model="certThreshold"> - </label> - <label v-if="makeCert"> - <span> - {{ $gettext('Hintergrundbild des Zertifikats wählen') }} - </span> - <courseware-file-chooser :isImage="true" v-model="certImage" - @selectFile="updateCertImage"></courseware-file-chooser> - </label> - </fieldset> - <fieldset> - <legend> - {{ $gettext('Erinnerungen') }} - </legend> - <label> - <input type="checkbox" name="sendreminders" v-model="sendReminders"> - <span> - {{ $gettext('Erinnerungsnachrichten an alle Teilnehmenden schicken') }} - </span> - <studip-tooltip-icon :text="$gettext('Hier können periodisch Nachrichten an alle ' + - 'Teilnehmenden verschickt werden, um z.B. an die Bearbeitung dieses Lernmaterials zu erinnern.')"/> - </label> - - <label v-if="sendReminders"> - <span> - {{ $gettext('Zeitraum zwischen Erinnerungen') }} - </span> - <select name="reminder_interval" v-model="reminderInterval"> - <option value="7"> - {{ $gettext('wöchentlich') }} - </option> - <option value="14"> - {{ $gettext('14-tägig') }} - </option> - <option value="30"> - {{ $gettext('monatlich') }} - </option> - <option value="90"> - {{ $gettext('vierteljährlich') }} - </option> - <option value="180"> - {{ $gettext('halbjährlich') }} - </option> - <option value="365"> - {{ $gettext('jährlich') }} - </option> - </select> - </label> - <label v-if="sendReminders" class="col-3"> - <span> - {{ $gettext('Erstmalige Erinnerung am') }} - <input type="date" name="reminder_start_date" - v-model="reminderStartDate"> - </span> - </label> - <label v-if="sendReminders" class="col-3"> - <span> - {{ $gettext('Letztmalige Erinnerung am') }} - <input type="date" name="reminder_end_date" - v-model="reminderEndDate"> - </span> - </label> - <label v-if="sendReminders"> - <span> - {{ $gettext('Text der Erinnerungsmail') }} - <textarea cols="70" rows="4" name="reminder_mail_text" data-editor="minimal" - v-model="reminderMailText"></textarea> - </span> - </label> - </fieldset> - <fieldset> - <legend> - {{ $gettext('Fortschritt') }} - </legend> - <label> - <input type="checkbox" name="resetprogress" v-model="resetProgress"> - <span> - {{ $gettext('Fortschritt periodisch auf 0 zurücksetzen') }} - </span> - <studip-tooltip-icon :text="$gettext('Hier kann eingestellt werden, den Fortschritt ' + - 'aller Teilnehmenden periodisch auf 0 zurückzusetzen.')"/> - </label> - <label v-if="resetProgress"> - <span> - {{ $gettext('Zeitraum zum Rücksetzen des Fortschritts') }} - </span> - <select name="reset_progress_interval" v-model="resetProgressInterval"> - <option value="14"> - {{ $gettext('14-tägig') }} - </option> - <option value="30"> - {{ $gettext('monatlich') }} - </option> - <option value="90"> - {{ $gettext('vierteljährlich') }} - </option> - <option value="180"> - {{ $gettext('halbjährlich') }} - </option> - <option value="365"> - {{ $gettext('jährlich') }} - </option> - </select> - </label> - <label v-if="resetProgress" class="col-3"> - <span> - {{ $gettext('Erstmaliges Zurücksetzen am') }} - <input type="date" dataformatas="" name="reset_progress_start_date" - v-model="resetProgressStartDate"> - </span> - </label> - <label v-if="resetProgress" class="col-3"> - <span> - {{ $gettext('Letztmaliges Zurücksetzen am') }} - <input type="date" name="reset_progress_end_date" - v-model="resetProgressEndDate"> - </span> - </label> - <label v-if="resetProgress"> - <span> - {{ $gettext('Text der Rücksetzungsmail') }} - <textarea cols="70" rows="4" name="reset_progress_mail_text" data-editor="minimal" - v-model="resetProgressMailText"></textarea> - </span> - </label> - </fieldset> - </form> - <button class="button" @click="store">{{ $gettext('Übernehmen') }}</button> - </div> -</template> - -<script> -import { mapActions, mapGetters } from 'vuex'; -import CoursewareFileChooser from "./CoursewareFileChooser.vue"; -import StudipTooltipIcon from '../StudipTooltipIcon.vue'; - -export default { - name: 'cw-tools-admin', - components: { StudipTooltipIcon, CoursewareFileChooser }, - data() { - return { - currentPermissionLevel: '', - currentProgression: '', - makeCert: false, - certThreshold: 0, - certImage: '', - sendReminders: false, - reminderInterval: 7, - reminderStartDate: '', - reminderEndDate: '', - reminderMailText: '', - resetProgress: false, - resetProgressInterval: 180, - resetProgressStartDate: '', - resetProgressEndDate: '', - resetProgressMailText: '' - }; - }, - computed: { - ...mapGetters({ - courseware: 'courseware', - }), - }, - methods: { - ...mapActions({ - storeCoursewareSettings: 'storeCoursewareSettings', - companionSuccess: 'companionSuccess', - }), - initData() { - console.log(this.courseware.attributes); - this.currentPermissionLevel = this.courseware.attributes['editing-permission-level']; - this.currentProgression = this.courseware.attributes['sequential-progression'] ? '1' : '0'; - this.certSettings = this.courseware.attributes['certificate-settings']; - this.makeCert = typeof(this.certSettings) === 'object' && - Object.keys(this.certSettings).length > 0; - this.certThreshold = this.certSettings.threshold; - this.certImage = this.certSettings.image; - this.reminderSettings = this.courseware.attributes['reminder-settings']; - this.sendReminders = typeof(this.reminderSettings) === 'object' && - Object.keys(this.reminderSettings).length > 0; - this.reminderInterval = this.reminderSettings.interval; - this.reminderStartDate = this.reminderSettings.startDate; - this.reminderEndDate = this.reminderSettings.endDate; - this.reminderMailText = this.reminderSettings.mailText; - this.resetProgressSettings = this.courseware.attributes['reset-progress-settings']; - this.resetProgress = typeof(this.resetProgressSettings) === 'object' && - Object.keys(this.resetProgressSettings).length > 0; - this.resetProgressInterval = this.resetProgressSettings.interval; - this.resetProgressStartDate = this.resetProgressSettings.startDate; - this.resetProgressEndDate = this.resetProgressSettings.endDate; - this.resetProgressMailText = this.resetProgressSettings.mailText; - }, - store() { - this.companionSuccess({ - info: this.$gettext('Die Einstellungen wurden übernommen.'), - }) - this.storeCoursewareSettings({ - permission: this.currentPermissionLevel, - progression: this.currentProgression, - certificateSettings: this.generateCertificateSettings(), - reminderSettings: this.generateReminderSettings(), - resetProgressSettings: this.generateResetProgressSettings() - }); - }, - generateCertificateSettings() { - return this.makeCert ? { - threshold: this.certThreshold, - image: this.certImage - } : {}; - }, - generateReminderSettings() { - return this.sendReminders ? { - interval: this.reminderInterval, - startDate: this.reminderStartDate, - endDate: this.reminderEndDate, - mailText: this.reminderMailText - } : {}; - }, - generateResetProgressSettings() { - return this.resetProgress ? { - interval: this.resetProgressInterval, - startDate: this.resetProgressStartDate, - endDate: this.resetProgressEndDate, - mailText: this.resetProgressMailText - } : {}; - }, - updateCertImage(file) { - this.certImage = file.id; - } - }, - mounted() { - this.initData(); - }, -}; -</script> diff --git a/resources/vue/components/courseware/CoursewareToolsBlockadder.vue b/resources/vue/components/courseware/CoursewareToolsBlockadder.vue index 2485eb3ec8948062a21971c190680b0eecd7afe2..97e5c47dc4e3ab625f7803dbd22d0ccaa242134d 100644 --- a/resources/vue/components/courseware/CoursewareToolsBlockadder.vue +++ b/resources/vue/components/courseware/CoursewareToolsBlockadder.vue @@ -110,8 +110,8 @@ import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; import CoursewareBlockadderItem from './CoursewareBlockadderItem.vue'; import CoursewareContainerAdderItem from './CoursewareContainerAdderItem.vue'; import CoursewareBlockHelper from './CoursewareBlockHelper.vue'; -import { mapGetters } from 'vuex'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import { mapActions, mapGetters } from 'vuex'; export default { name: 'cw-tools-blockadder', @@ -185,6 +185,11 @@ export default { } }, methods: { + ...mapActions({ + removeFavoriteBlockType: 'removeFavoriteBlockType', + addFavoriteBlockType: 'addFavoriteBlockType', + coursewareContainerAdder: 'coursewareContainerAdder' + }), displayContainerAdder() { this.showContaineradder = true; this.showBlockadder = false; @@ -196,9 +201,9 @@ export default { }, toggleFavItem(block) { if (this.isBlockFav(block)) { - this.$store.dispatch('removeFavoriteBlockType', block.type); + this.removeFavoriteBlockType(block.type); } else { - this.$store.dispatch('addFavoriteBlockType', block.type); + this.addFavoriteBlockType(block.type); } }, isBlockFav(block) { @@ -212,7 +217,7 @@ export default { return isFav; }, disableContainerAdder() { - this.$store.dispatch('coursewareContainerAdder', false); + this.coursewareContainerAdder(false); }, endEditFavs() { this.showEditFavs = false; diff --git a/resources/vue/components/courseware/CoursewareUnitItem.vue b/resources/vue/components/courseware/CoursewareUnitItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..a6483321b46c5b1d8ec2985f26731baf86ae4bd2 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareUnitItem.vue @@ -0,0 +1,184 @@ +<template> + <div class="courseware-unit-item"> + <courseware-tile + tag="li" + :color="color" + :title="title" + :descriptionLink="url" + :descriptionTitle="$gettext('Lernmaterial öffnen')" + :displayProgress="inCourseContext" + :progress="progress" + :imageUrl="imageUrl" + > + <template #image-overlay-with-action-menu> + <studip-action-menu + class="cw-unit-action-menu" + :items="menuItems" + :context="title" + @showDelete="openDeleteDialog" + @showExport="openExportDialog" + @showProgress="openProgressDialog" + @showSettings="openSettingsDialog" + @copyUnit="copy" + /> + </template> + <template #description> + {{ description }} + </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 } + )" + height="200" + @confirm="executeDelete" + @close="closeDeleteDialog" + ></studip-dialog> + + <studip-dialog + v-if="showProgressDialog" + :title="$gettext('Fortschritt')" + :closeText="$gettext('Schließen')" + closeClass="cancel" + width="800" + height="600" + @close="closeProgressDialog" + > + <template v-slot:dialogContent> + <courseware-unit-progress :progressData="progresses" :unitId="unit.id" :rootId="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"/> + </div> +</template> + +<script> +import CoursewareTile from './CoursewareTile.vue'; +import CoursewareUnitItemDialogExport from './CoursewareUnitItemDialogExport.vue'; +import CoursewareUnitItemDialogSettings from './CoursewareUnitItemDialogSettings.vue'; +import CoursewareUnitProgress from './CoursewareUnitProgress.vue'; + +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-unit-item', + components: { + CoursewareTile, + CoursewareUnitItemDialogExport, + CoursewareUnitItemDialogSettings, + CoursewareUnitProgress, + }, + props: { + unit: Object, + }, + data() { + return { + showDeleteDialog: false, + showExportDialog: false, + showSettingsDialog: false, + showProgressDialog: false, + progresses: null + } + }, + computed: { + ...mapGetters({ + context: 'context', + structuralElementById: 'courseware-structural-elements/byId', + userIsTeacher: 'userIsTeacher' + }), + menuItems() { + let menu = []; + if (this.inCourseContext) { + menu.push({ id: 1, label: this.$gettext('Fortschritt'), icon: 'check-circle', emit: 'showProgress' }); + } + if(this.userIsTeacher && this.inCourseContext) { + menu.push({ id: 2, label: this.$gettext('Einstellungen'), icon: 'admin', emit: 'showSettings' }); + } + if(this.userIsTeacher || !this.inCourseContext) { + menu.push({ id: 3, label: this.$gettext('Kopieren'), icon: 'files', emit: 'copyUnit' }); + menu.push({ id: 4, label: this.$gettext('Exportieren'), icon: 'export', emit: 'showExport' }); + menu.push({ id: 5, label: this.$gettext('Löschen'), icon: 'trash', emit: 'showDelete' }); + } + + return menu; + }, + unitElement() { + return this.structuralElementById({id: this.unit.relationships['structural-element'].data.id}) ?? null; + }, + color() { + return this.unitElement?.attributes?.payload?.color ?? 'studip-blue'; + }, + title() { + return this.unitElement?.attributes?.title ?? ''; + }, + 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 }); + } else { + return STUDIP.URLHelper.getURL('dispatch.php/contents/courseware/courseware/' + this.unit.id); + } + }, + progress() { + if (this.unitElement) { + return this.progresses?.[this.unitElement.id]?.progress?.cumulative ?? 0; + } + return 0; + }, + inCourseContext() { + return this.context.type === 'courses'; + } + }, + async mounted() { + if (this.inCourseContext) { + this.progresses = await this.loadUnitProgresses({unitId: this.unit.id}); + } + }, + methods: { + ...mapActions({ + deleteUnit: 'deleteUnit', + loadUnitProgresses: 'loadUnitProgresses', + copyUnit: 'copyUnit', + companionSuccess: 'companionSuccess' + }), + executeDelete() { + this.deleteUnit({id: this.unit.id}); + }, + openDeleteDialog() { + this.showDeleteDialog = true; + }, + closeDeleteDialog() { + this.showDeleteDialog = false; + }, + openExportDialog() { + this.showExportDialog = true; + }, + async openProgressDialog() { + this.showProgressDialog = true; + this.progresses = await this.loadUnitProgresses({unitId: this.unit.id}); + }, + closeProgressDialog() { + this.showProgressDialog = false; + }, + openSettingsDialog() { + this.showSettingsDialog = true; + }, + closeSettingsDialog() { + this.showSettingsDialog = false; + }, + async copy() { + await this.copyUnit({unitId: this.unit.id, modified: null}); + this.companionSuccess({ info: this.$gettext('Lernmaterial kopiert.') }); } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareUnitItemDialogExport.vue b/resources/vue/components/courseware/CoursewareUnitItemDialogExport.vue new file mode 100644 index 0000000000000000000000000000000000000000..a9c43766d75b7ee39ad3661d39c547a9e1a03d61 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareUnitItemDialogExport.vue @@ -0,0 +1,132 @@ +<template> + <studip-dialog + :title="$gettext('Lernmaterial exportieren')" + :confirmText="$gettext('Exportieren')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="350" + @close="$emit('close')" + @confirm="executeExport" + > + <template v-slot:dialogContent> + <courseware-companion-box + v-show="!exportRunning" + :msgCompanion="$gettextInterpolate($gettext('Export des Lernmaterials: %{title}'), {title: title})" + mood="curious" + /> + + <courseware-companion-box + v-show="exportRunning" + :msgCompanion="$gettextInterpolate($gettext('%{title} wird exportiert, bitte haben sie einen Moment Geduld...'), {title: title})" + mood="pointing" + /> + <div v-show="exportRunning" class="cw-import-zip"> + <header>{{ exportState }}:</header> + <div class="progress-bar-wrapper"> + <div + class="progress-bar" + role="progressbar" + :style="{ width: exportProgress + '%' }" + :aria-valuenow="exportProgress" + aria-valuemin="0" + aria-valuemax="100" + > + {{ exportProgress }}% + </div> + </div> + </div> + </template> + </studip-dialog> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import CoursewareExport from '@/vue/mixins/courseware/export.js'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-unit-item-dialog-export', + mixins: [CoursewareExport], + components: { + CoursewareCompanionBox, + }, + props: { + unit: Object + }, + data() { + return { + currentInstance: null, + exportRunning: false, + } + }, + computed: { + ...mapGetters({ + context: 'context', + exportProgress: 'exportProgress', + exportState: 'exportState', + instanceById: 'courseware-instances/byId', + structuralElementById: 'courseware-structural-elements/byId', + userIsTeacher: 'userIsTeacher', + }), + instance() { + if (this.inCourseContext) { + return this.instanceById({id: 'course_' + this.context.id + '_' + this.unit.id}); + } else { + return this.instanceById({id: 'user_' + this.context.id + '_' + this.unit.id}); + } + + }, + inCourseContext() { + return this.context.type === 'courses'; + }, + unitElement() { + return this.structuralElementById({id: this.unit.relationships['structural-element'].data.id}) ?? null; + }, + title() { + return this.unitElement?.attributes?.title ?? ''; + }, + }, + methods: { + ...mapActions({ + loadInstance: 'loadInstance', + setExportState: 'setExportState', + companionSuccess: 'companionSuccess' + }), + async loadUnitInstance() { + const context = {type: this.context.type, id: this.context.id, unit: this.unit.id}; + await this.loadInstance(context); + }, + async executeExport() { + if (this.exportRunning) { + return; + } + + this.exportRunning = true; + + this.setExportState(this.$gettext('Lade Einstellungen')); + await this.loadUnitInstance(); + this.setExportState(''); + + await this.sendExportZip(this.unitElement.id, { + withChildren: true, + completeExport: true, + settings: { + 'editing-permission-level': this.instance.attributes['editing-permission-level'] ?? 'tutor', + 'sequential-progression': this.instance.attributes['sequential-progression'] ?? 0, + 'certificate-settings': this.instance.attributes['certificate-settings'], + 'reminder-settings': this.instance.attributes['reminder-settings'], + 'reset-progress-settings': this.instance.attributes['reset-progress-settings'] + } + }); + + this.exportRunning = false; + this.$emit('close'); + }, + + }, + async mounted() { + await this.loadUnitInstance(); + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareUnitItemDialogSettings.vue b/resources/vue/components/courseware/CoursewareUnitItemDialogSettings.vue new file mode 100644 index 0000000000000000000000000000000000000000..8438df308b3d1c9cd09090b786feb377504bb11a --- /dev/null +++ b/resources/vue/components/courseware/CoursewareUnitItemDialogSettings.vue @@ -0,0 +1,311 @@ +<template> + <studip-dialog + :title="$gettext('Einstellungen')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="600" + width="500" + @close="$emit('close')" + @confirm="storeSettings" + > + <template v-slot:dialogContent> + <form v-if="!loadSettings" class="default" @submit.prevent=""> + <fieldset> + <legend>{{ $gettext('Allgemeine Einstellungen') }}</legend> + <label> + <span>{{ $gettext('Art der Inhaltsabfolge') }}</span> + <select class="size-s" v-model="currentProgression"> + <option value="0">{{ $gettext('Frei') }}</option> + <option value="1">{{ $gettext('Sequentiell') }}</option> + </select> + </label> + + <label> + <span>{{ $gettext('Editierberechtigung für Tutor/-innen') }}</span> + <select class="size-s" v-model="currentPermissionLevel"> + <option value="dozent">{{ $gettext('Nein') }}</option> + <option value="tutor">{{ $gettext('Ja') }}</option> + </select> + </label> + </fieldset> + <fieldset> + <legend>{{ $gettext('Zertifikate') }}</legend> + <label> + <input type="checkbox" name="makecert" v-model="makeCert"> + <span> + {{ $gettext('Zertifikat bei Erreichen einer Fortschrittsgrenze versenden') }} + </span> + <studip-tooltip-icon :text="$gettext('Erreicht eine Person in diesem Lernmaterial den ' + + 'hier eingestellten Fortschritt, so erhält Sie ein PDF-Zertifikat per E-Mail.')"/> + </label> + <label v-if="makeCert"> + <span> + {{ $gettext('Erforderlicher Fortschritt (in Prozent), um ein Zertifikat zu erhalten') }} + </span> + <input type="number" min="1" max="100" name="threshold" v-model="certThreshold"> + </label> + <label v-if="makeCert"> + <span> + {{ $gettext('Hintergrundbild des Zertifikats wählen') }} + </span> + <courseware-file-chooser :isImage="true" v-model="certImage" @selectFile="updateCertImage" /> + </label> + </fieldset> + <fieldset> + <legend> + {{ $gettext('Erinnerungen') }} + </legend> + <label> + <input type="checkbox" name="sendreminders" v-model="sendReminders"> + <span> + {{ $gettext('Erinnerungsnachrichten an alle Teilnehmenden schicken') }} + </span> + <studip-tooltip-icon :text="$gettext('Hier können periodisch Nachrichten an alle ' + + 'Teilnehmenden verschickt werden, um z.B. an die Bearbeitung dieses Lernmaterials zu erinnern.')"/> + </label> + + <label v-if="sendReminders"> + <span> + {{ $gettext('Zeitraum zwischen Erinnerungen') }} + </span> + <select name="reminder_interval" v-model="reminderInterval"> + <option value="7"> + {{ $gettext('wöchentlich') }} + </option> + <option value="14"> + {{ $gettext('14-tägig') }} + </option> + <option value="30"> + {{ $gettext('monatlich') }} + </option> + <option value="90"> + {{ $gettext('vierteljährlich') }} + </option> + <option value="180"> + {{ $gettext('halbjährlich') }} + </option> + <option value="365"> + {{ $gettext('jährlich') }} + </option> + </select> + </label> + <label v-if="sendReminders" class="col-3"> + <span> + {{ $gettext('Erstmalige Erinnerung am') }} + <input type="date" name="reminder_start_date" + v-model="reminderStartDate"> + </span> + </label> + <label v-if="sendReminders" class="col-3"> + <span> + {{ $gettext('Letztmalige Erinnerung am') }} + <input type="date" name="reminder_end_date" + v-model="reminderEndDate"> + </span> + </label> + <label v-if="sendReminders"> + <span> + {{ $gettext('Text der Erinnerungsmail') }} + <textarea cols="70" rows="4" name="reminder_mail_text" data-editor="minimal" + v-model="reminderMailText"></textarea> + </span> + </label> + </fieldset> + <fieldset> + <legend> + {{ $gettext('Fortschritt') }} + </legend> + <label> + <input type="checkbox" name="resetprogress" v-model="resetProgress"> + <span> + {{ $gettext('Fortschritt periodisch auf 0 zurücksetzen') }} + </span> + <studip-tooltip-icon :text="$gettext('Hier kann eingestellt werden, den Fortschritt ' + + 'aller Teilnehmenden periodisch auf 0 zurückzusetzen.')"/> + </label> + <label v-if="resetProgress"> + <span> + {{ $gettext('Zeitraum zum Rücksetzen des Fortschritts') }} + </span> + <select name="reset_progress_interval" v-model="resetProgressInterval"> + <option value="14"> + {{ $gettext('14-tägig') }} + </option> + <option value="30"> + {{ $gettext('monatlich') }} + </option> + <option value="90"> + {{ $gettext('vierteljährlich') }} + </option> + <option value="180"> + {{ $gettext('halbjährlich') }} + </option> + <option value="365"> + {{ $gettext('jährlich') }} + </option> + </select> + </label> + <label v-if="resetProgress" class="col-3"> + <span> + {{ $gettext('Erstmaliges Zurücksetzen am') }} + <input type="date" dataformatas="" name="reset_progress_start_date" + v-model="resetProgressStartDate"> + </span> + </label> + <label v-if="resetProgress" class="col-3"> + <span> + {{ $gettext('Letztmaliges Zurücksetzen am') }} + <input type="date" name="reset_progress_end_date" + v-model="resetProgressEndDate"> + </span> + </label> + <label v-if="resetProgress"> + <span> + {{ $gettext('Text der Rücksetzungsmail') }} + <textarea cols="70" rows="4" name="reset_progress_mail_text" data-editor="minimal" + v-model="resetProgressMailText"></textarea> + </span> + </label> + </fieldset> + </form> + <studip-progress-indicator v-else :description="$gettext('Lade Einstellungen…')"/> + + </template> + </studip-dialog> +</template> + +<script> +import CoursewareFileChooser from "./CoursewareFileChooser.vue"; +import StudipProgressIndicator from '../StudipProgressIndicator.vue'; + +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-unit-item-dialog-settings', + components: { + CoursewareFileChooser, + StudipProgressIndicator, + }, + props: { + unit: Object + }, + data() { + return { + currentInstance: null, + loadSettings: false, + currentPermissionLevel: '', + currentProgression: 0, + makeCert: false, + certThreshold: 0, + certImage: '', + sendReminders: false, + reminderInterval: 7, + reminderStartDate: '', + reminderEndDate: '', + reminderMailText: '', + resetProgress: false, + resetProgressInterval: 180, + resetProgressStartDate: '', + resetProgressEndDate: '', + resetProgressMailText: '' + } + }, + computed: { + ...mapGetters({ + context: 'context', + instanceById: 'courseware-instances/byId', + userIsTeacher: 'userIsTeacher' + }), + instance() { + if (this.inCourseContext) { + return this.instanceById({id: 'course_' + this.context.id + '_' + this.unit.id}); + } else { + return this.instanceById({id: 'user_' + this.context.id + '_' + this.unit.id}); + } + + }, + inCourseContext() { + return this.context.type === 'courses'; + } + }, + methods: { + ...mapActions({ + loadInstance: 'loadInstance', + storeCoursewareSettings: 'storeCoursewareSettings', + companionSuccess: 'companionSuccess' + }), + async loadUnitInstance() { + const context = {type: this.context.type, id: this.context.id, unit: this.unit.id}; + await this.loadInstance(context); + }, + initData() { + this.currentPermissionLevel = this.currentInstance.attributes['editing-permission-level']; + this.currentProgression = this.currentInstance.attributes['sequential-progression'] ? '1' : '0'; + this.certSettings = this.currentInstance.attributes['certificate-settings']; + this.makeCert = typeof(this.certSettings) === 'object' && + Object.keys(this.certSettings).length > 0; + this.certThreshold = this.certSettings.threshold; + this.certImage = this.certSettings.image; + this.reminderSettings = this.currentInstance.attributes['reminder-settings']; + this.sendReminders = typeof(this.reminderSettings) === 'object' && + Object.keys(this.reminderSettings).length > 0; + this.reminderInterval = this.reminderSettings.interval; + this.reminderStartDate = this.reminderSettings.startDate; + this.reminderEndDate = this.reminderSettings.endDate; + this.reminderMailText = this.reminderSettings.mailText; + this.resetProgressSettings = this.currentInstance.attributes['reset-progress-settings']; + this.resetProgress = typeof(this.resetProgressSettings) === 'object' && + Object.keys(this.resetProgressSettings).length > 0; + this.resetProgressInterval = this.resetProgressSettings.interval; + this.resetProgressStartDate = this.resetProgressSettings.startDate; + this.resetProgressEndDate = this.resetProgressSettings.endDate; + this.resetProgressMailText = this.resetProgressSettings.mailText; + }, + storeSettings() { + this.$emit('close'); + this.currentInstance.attributes['editing-permission-level'] = this.currentPermissionLevel; + this.currentInstance.attributes['sequential-progression'] = this.currentProgression; + this.currentInstance.attributes['certificate-settings'] = this.generateCertificateSettings(); + this.currentInstance.attributes['reminder-settings'] = this.generateReminderSettings(); + this.currentInstance.attributes['reset-progress-settings'] = this.generateResetProgressSettings(); + this.storeCoursewareSettings({ + instance: this.currentInstance, + }); + }, + generateCertificateSettings() { + return this.makeCert ? { + threshold: this.certThreshold, + image: this.certImage + } : {}; + }, + generateReminderSettings() { + return this.sendReminders ? { + interval: this.reminderInterval, + startDate: this.reminderStartDate, + endDate: this.reminderEndDate, + mailText: this.reminderMailText + } : {}; + }, + generateResetProgressSettings() { + return this.resetProgress ? { + interval: this.resetProgressInterval, + startDate: this.resetProgressStartDate, + endDate: this.resetProgressEndDate, + mailText: this.resetProgressMailText + } : {}; + }, + updateCertImage(file) { + this.certImage = file.id; + } + }, + async mounted() { + this.loadSettings = true; + await this.loadUnitInstance(); + this.loadSettings = false; + this.currentInstance = this.instance; + this.initData(); + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareUnitItems.vue b/resources/vue/components/courseware/CoursewareUnitItems.vue new file mode 100644 index 0000000000000000000000000000000000000000..5995baa7f19d2837789e987fb53e694f80c9ec47 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareUnitItems.vue @@ -0,0 +1,77 @@ +<template> + <div class="cw-unit-items"> + <ul v-if="hasUnits" class="cw-tiles"> + <courseware-unit-item v-for="unit in units" :key="unit.id" :unit="unit"/> + </ul> + <div v-if="!hasUnits && userIsTeacher && inCourseContext" class="cw-contents-overview-teaser"> + <div class="cw-contents-overview-teaser-content"> + <header>{{ $gettext('Lernmaterialien') }}</header> + <p> + {{ $gettext('Mit Courseware können Sie interaktive, multimediale Lerninhalte erstellen und nutzen. ' + + 'Die Lerninhalte lassen sich hierarchisch unterteilen und können aus Texten, Videosequenzen, ' + + 'Aufgaben, Kommunikationselementen und einer Vielzahl weiterer Elemente bestehen. ' + + 'Fertige Lerninhalte können exportiert und in andere Kurse oder andere Installationen importiert werden. ' + + 'Courseware ist nicht nur für digitale Formate geeignet, sondern kann auch genutzt werden, ' + + 'um klassische Präsenzveranstaltungen mit Online-Anteilen zu ergänzen. Formate wie integriertes Lernen ' + + '(Blended Learning) lassen sich mit Courseware ideal umsetzen. Kollaboratives Lernen kann dank Schreibrechtevergabe ' + + 'und dem Einsatz von Courseware in Studiengruppen realisiert werden.') }} + </p> + <button class="button" @click="setShowUnitAddDialog(true)"> + {{ $gettext('Neues Lernmaterial anlegen') }} + </button> + </div> + </div> + <courseware-companion-box + v-if="!userIsTeacher && inCourseContext" + :msgCompanion="$gettext('Es wurden leider noch keine Lernmaterialien angelegt.')" + mood="sad" + /> + <div v-if="!hasUnits && !inCourseContext" class="cw-contents-overview-teaser"> + <div class="cw-contents-overview-teaser-content"> + <header>{{ $gettext('Ihre persönlichen Lernmaterialien') }}</header> + <p>{{ $gettext('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.') }}</p> + <button class="button" @click="setShowUnitAddDialog(true)"> + {{ $gettext('Neues Lernmaterial anlegen') }} + </button> + </div> + </div> + </div> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import CoursewareUnitItem from './CoursewareUnitItem.vue'; + +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-unit-items', + components: { + CoursewareCompanionBox, + CoursewareUnitItem, + }, + computed: { + ...mapGetters({ + context: 'context', + coursewareUnits: 'courseware-units/all', + userIsTeacher: 'userIsTeacher' + }), + units() { + return this.coursewareUnits.filter(unit => unit.relationships.range.data.id === this.context.id) ?? []; + }, + hasUnits() { + return this.units.length > 0; + }, + inCourseContext() { + return this.context.type === 'courses'; + } + }, + methods: { + ...mapActions({ + setShowUnitAddDialog: 'setShowUnitAddDialog', + }), + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareDashboardProgress.vue b/resources/vue/components/courseware/CoursewareUnitProgress.vue similarity index 72% rename from resources/vue/components/courseware/CoursewareDashboardProgress.vue rename to resources/vue/components/courseware/CoursewareUnitProgress.vue index 6594318a7422662608f0e48a36c5c1fae7ca04be..61b53500a6bab95915c07676cf3881569d2bb5a2 100644 --- a/resources/vue/components/courseware/CoursewareDashboardProgress.vue +++ b/resources/vue/components/courseware/CoursewareUnitProgress.vue @@ -1,6 +1,6 @@ <template> - <div class="cw-dashboard-progress"> - <nav aria-label="Breadcrumb" class="cw-dashboard-progress-breadcrumb"> + <div class="cw-unit-progress"> + <nav aria-label="Breadcrumb" class="cw-unit-progress-breadcrumb"> <a v-if="parent" href="#" @@ -18,8 +18,8 @@ / {{ parent.name }} </a> </nav> - <div v-if="selected" class="cw-dashboard-progress-chapter"> - <a :href="chapterUrl" :title="$gettextInterpolate($gettext('%{ pageTitle } öffnen'), {pageTitle: selected.name})"> + <div v-if="selected" class="cw-unit-progress-chapter"> + <a :href="chapterUrl" :title="$gettextInterpolate('%{ pageTitle } öffnen', {pageTitle: selected.name})"> <h1>{{ selected.name }}</h1> </a> <courseware-progress-circle @@ -28,12 +28,12 @@ /> <courseware-progress-circle :title="$gettext('diese Seite')" - class="cw-dashboard-progress-current" + class="cw-unit-progress-current" :value="parseInt(selected.progress.self)" /> </div> - <div class="cw-dashboard-progress-subchapter-list"> - <courseware-dashboard-progress-item + <div class="cw-unit-progress-subchapter-list"> + <courseware-unit-progress-item v-for="chapter in children" :key="chapter.id" :name="chapter.name" @@ -41,8 +41,8 @@ :chapterId="chapter.id" @selectChapter="selectChapter" /> - <div v-if="!children.length" class="cw-dashboard-empty-info"> - <courseware-companion-box + <div v-if="!children.length" class="cw-unit-empty-info"> + <courseware-companion-box mood="sad" :msgCompanion="$gettext('Diese Seite enthält keine darunter liegenden Seiten.')" /> @@ -52,32 +52,36 @@ </template> <script> -import StudipIcon from '../StudipIcon.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; -import CoursewareDashboardProgressItem from './CoursewareDashboardProgressItem.vue'; +import CoursewareUnitProgressItem from './CoursewareUnitProgressItem.vue'; import CoursewareProgressCircle from './CoursewareProgressCircle.vue'; +import StudipIcon from '../StudipIcon.vue'; export default { - name: 'courseware-dashboard-progress', + name: 'courseware-unit-progress', components: { CoursewareCompanionBox, - CoursewareDashboardProgressItem, + CoursewareUnitProgressItem, CoursewareProgressCircle, StudipIcon, }, + props: { + progressData: Object, + unitId: String, + rootId: String + }, data() { return { selected: null, }; }, computed: { - progressData() { - return STUDIP.courseware_progress_data; - }, chapterUrl() { return ( STUDIP.URLHelper.base_url + - 'dispatch.php/course/courseware/?cid=' + + 'dispatch.php/course/courseware/courseware/'+ + this.unitId + + '?cid=' + STUDIP.URLHelper.parameters.cid + '#/structural_element/' + this.selected.id @@ -100,7 +104,7 @@ export default { }, methods: { visitRoot() { - this.selected = Object.values(this.progressData).find(({ parent_id }) => !!parent_id) ?? null; + this.selected = this.progressData[this.rootId]; }, selectChapter(id) { this.selected = this.progressData[id] ?? null; diff --git a/resources/vue/components/courseware/CoursewareDashboardProgressItem.vue b/resources/vue/components/courseware/CoursewareUnitProgressItem.vue similarity index 71% rename from resources/vue/components/courseware/CoursewareDashboardProgressItem.vue rename to resources/vue/components/courseware/CoursewareUnitProgressItem.vue index e5ec0d23567f1b5b952f62bcb338a018e6990752..b83a69289841360bd5b7c4af14e55d30835a8d93 100644 --- a/resources/vue/components/courseware/CoursewareDashboardProgressItem.vue +++ b/resources/vue/components/courseware/CoursewareUnitProgressItem.vue @@ -1,14 +1,14 @@ <template> <a href="#" - class="cw-dashboard-progress-item" + class="cw-unit-progress-item" :title="name" @click="$emit('selectChapter', chapterId)" > - <div class="cw-dashboard-progress-item-value"> + <div class="cw-unit-progress-item-value"> <courseware-progress-circle :value="parseInt(value)" /> </div> - <div class="cw-dashboard-progress-item-description"> + <div class="cw-unit-progress-item-description"> {{ name }} </div> </a> @@ -18,7 +18,7 @@ import CoursewareProgressCircle from './CoursewareProgressCircle.vue'; export default { - name: 'courseware-dashboard-progress-item', + name: 'courseware-unit-progress-item', components: { CoursewareProgressCircle, }, diff --git a/resources/vue/components/courseware/CoursewareViewWidget.vue b/resources/vue/components/courseware/CoursewareViewWidget.vue index fb3de7441a2c90ad5387472c7228c5bf37089422..f6ab44c21e5736cb41f65d1345f4321786008aa5 100644 --- a/resources/vue/components/courseware/CoursewareViewWidget.vue +++ b/resources/vue/components/courseware/CoursewareViewWidget.vue @@ -74,7 +74,7 @@ export default { this.coursewareViewMode('edit'); }, setDiscussView() { - this.$store.dispatch('coursewareViewMode', 'discuss'); + this.coursewareViewMode('discuss'); }, }, }; diff --git a/resources/vue/components/courseware/CoursewareWellcomeScreen.vue b/resources/vue/components/courseware/CoursewareWelcomeScreen.vue similarity index 57% rename from resources/vue/components/courseware/CoursewareWellcomeScreen.vue rename to resources/vue/components/courseware/CoursewareWelcomeScreen.vue index 52a629eaf4633b02f6b63edf477ff7a7b1441e5b..0f0352f39ae7c4ca06c24bd1bfef44b8d0981696 100644 --- a/resources/vue/components/courseware/CoursewareWellcomeScreen.vue +++ b/resources/vue/components/courseware/CoursewareWelcomeScreen.vue @@ -1,16 +1,19 @@ <template> - <div class="cw-wellcome-screen"> - <div class="cw-wellcome-screen-keyvisual"></div> + <div class="cw-welcome-screen"> + <div class="cw-welcome-screen-keyvisual"></div> <header> - <translate>Willkommen bei Courseware</translate> + {{ $gettext('Willkommen bei Courseware') }} </header> - <div class="cw-wellcome-screen-actions"> - <a href="https://hilfe.studip.de/help/5.0/de/Basis.Courseware" target="_blank" class="button"> - <translate>Mehr über Courseware erfahren</translate> + <div class="cw-welcome-screen-actions"> + <a href="https://hilfe.studip.de/help/5.3/de/Basis.Courseware" target="_blank" class="button"> + {{ $gettext('Mehr über Courseware erfahren') }} </a> - <button class="button" :title="$gettext('Fügt einen Standard-Abschnitt mit einem Text-Block hinzu')" @click="addDefault"><translate>Ersten Inhalt erstellen</translate></button> - <button class="button" @click="addContainer"><translate>Einen Abschnitt auswählen</translate></button> - + <button class="button" :title="$gettext('Fügt einen Standard-Abschnitt mit einem Text-Block hinzu')" @click="addDefault"> + {{ $gettext('Ersten Inhalt erstellen') }} + </button> + <button class="button" @click="addContainer"> + {{ $gettext('Einen Abschnitt auswählen') }} + </button> </div> </div> </template> @@ -19,16 +22,12 @@ import { mapActions, mapGetters } from 'vuex'; export default { - name: 'courseware-wellcome-screen', - components: { - }, - props: {}, - data() { - return{} - }, + name: 'courseware-welcome-screen', computed: { ...mapGetters({ - consumeMode: 'consumeMode' + consumeMode: 'consumeMode', + lastCreatedBlocks: 'courseware-blocks/lastCreated', + lastCreatedContainers: 'courseware-containers/lastCreated' }), }, methods: { @@ -40,12 +39,18 @@ export default { updateContainer: 'updateContainer', lockObject: 'lockObject', unlockObject: 'unlockObject', + + coursewareConsumeMode: 'coursewareConsumeMode', + coursewareViewMode: 'coursewareViewMode', + coursewareContainerAdder: 'coursewareContainerAdder', + coursewareShowToolbar: 'coursewareShowToolbar' + }), addContainer() { - this.$store.dispatch('coursewareConsumeMode', false); - this.$store.dispatch('coursewareViewMode', 'edit'); - this.$store.dispatch('coursewareContainerAdder', true); - this.$store.dispatch('coursewareShowToolbar', true); + this.coursewareConsumeMode(false); + this.coursewareViewMode('edit'); + this.coursewareContainerAdder(true); + this.coursewareShowToolbar(true); }, async addDefault() { let attributes = {}; @@ -55,19 +60,19 @@ export default { sections: [{ name: 'Liste', icon: '', blocks: [] }], }; await this.createContainer({ structuralElementId: this.$route.params.id, attributes: attributes }); - let newContainer = this.$store.getters['courseware-containers/lastCreated']; + let newContainer = this.lastCreatedContainers; await this.lockObject({ id: newContainer.id, type: 'courseware-containers' }); await this.createBlock({ container: newContainer, section: 0, blockType: 'text', }); - this.$store.dispatch('coursewareViewMode', 'edit'); - this.$store.dispatch('coursewareConsumeMode', false); + this.coursewareViewMode('edit'); + this.coursewareConsumeMode(false); this.companionSuccess({ - info: this.$gettext('Das Elemente für Ihren ersten Inhalt wurden angelegt.'), + info: this.$gettext('Das Elemente für Ihren ersten Inhalt wurde angelegt.'), }); - const newBlock = this.$store.getters['courseware-blocks/lastCreated']; + const newBlock = this.lastCreatedBlocks; newContainer.attributes.payload.sections[0].blocks.push(newBlock.id); const structuralElementId = this.$route.params.id await this.updateContainer({ container: newContainer, structuralElementId: structuralElementId }); diff --git a/resources/vue/components/courseware/DashboardApp.vue b/resources/vue/components/courseware/DashboardApp.vue deleted file mode 100644 index 34290c55863584a008390fcd5899a233131df58c..0000000000000000000000000000000000000000 --- a/resources/vue/components/courseware/DashboardApp.vue +++ /dev/null @@ -1,36 +0,0 @@ -<template> - <div class="cw-dashboard-wrapper"> - <courseware-course-dashboard></courseware-course-dashboard> - <MountingPortal mountTo="#courseware-dashboard-view-widget" name="sidebar-views"> - <courseware-dashboard-view-widget></courseware-dashboard-view-widget> - </MountingPortal> - </div> -</template> - -<script> -import CoursewareCourseDashboard from './CoursewareCourseDashboard.vue'; -import CoursewareDashboardViewWidget from './CoursewareDashboardViewWidget.vue'; -import { mapActions, mapGetters } from 'vuex'; - -export default { - components: { - CoursewareCourseDashboard, - CoursewareDashboardViewWidget - }, - computed: { - ...mapGetters({ - userId: 'userId', - }), - }, - methods: { - ...mapActions({ - loadCoursewareStructure: 'courseware-structure/load', - loadTeacherStatus: 'loadTeacherStatus', - }), - }, - async mounted() { - await this.loadCoursewareStructure(); - await this.loadTeacherStatus(this.userId); - } -}; -</script> diff --git a/resources/vue/components/courseware/IndexApp.vue b/resources/vue/components/courseware/IndexApp.vue index 7c9c15304b14fce1e429664e86638471b356c736..c0facd5d2861e1d24c42a324125db701ea54d081 100644 --- a/resources/vue/components/courseware/IndexApp.vue +++ b/resources/vue/components/courseware/IndexApp.vue @@ -10,16 +10,19 @@ @select="selectStructuralElement" ></courseware-structural-element> <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions"> - <courseware-action-widget :structural-element="selected" :canVisit="canVisit" v-if="!showSearchResults"></courseware-action-widget> + <courseware-action-widget v-if="!showSearchResults && canEditSelected" :structural-element="selected"></courseware-action-widget> </MountingPortal> <MountingPortal mountTo="#courseware-search-widget" name="sidebar-search"> - <courseware-search-widget></courseware-search-widget> + <courseware-search-widget v-if="selected !== null"></courseware-search-widget> </MountingPortal> <MountingPortal mountTo="#courseware-view-widget" name="sidebar-views"> - <courseware-view-widget :structural-element="selected" :canVisit="canVisit" v-if="!showSearchResults"></courseware-view-widget> + <courseware-view-widget v-if="!showSearchResults" :structural-element="selected" :canVisit="canVisit"></courseware-view-widget> + </MountingPortal> + <MountingPortal mountTo="#courseware-import-widget" name="sidebar-import"> + <courseware-import-widget v-if="!showSearchResults && canEditSelected" :structural-element="selected"></courseware-import-widget> </MountingPortal> <MountingPortal mountTo="#courseware-export-widget" name="sidebar-export"> - <courseware-export-widget :structural-element="selected" :canVisit="canVisit" v-if="!showSearchResults"></courseware-export-widget> + <courseware-export-widget v-if="!showSearchResults" :structural-element="selected" :canVisit="canVisit"></courseware-export-widget> </MountingPortal> </div> <studip-progress-indicator @@ -42,6 +45,7 @@ import CoursewareSearchResults from './CoursewareSearchResults.vue'; import CoursewareViewWidget from './CoursewareViewWidget.vue'; import CoursewareActionWidget from './CoursewareActionWidget.vue'; import CoursewareExportWidget from './CoursewareExportWidget.vue'; +import CoursewareImportWidget from './CoursewareImportWidget.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareSearchWidget from './CoursewareSearchWidget.vue'; import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; @@ -58,6 +62,7 @@ export default { CoursewareCompanionBox, StudipProgressIndicator, CoursewareExportWidget, + CoursewareImportWidget, CoursewareSearchWidget, CoursewareCompanionOverlay, }, @@ -89,6 +94,13 @@ export default { default: return this.$gettext('Beim Laden der Seite ist ein Fehler aufgetreten.'); } + }, + canEditSelected() { + if (this.selected) { + return this.selected.attributes['can-edit']; + } + + return false; } }, methods: { @@ -98,7 +110,6 @@ export default { invalidateStructureCache: 'courseware-structure/invalidateCache', loadCoursewareStructure: 'courseware-structure/load', loadStructuralElement: 'loadStructuralElement', - loadTeacherStatus: 'loadTeacherStatus', }), async selectStructuralElement(id) { if (!id) { @@ -120,9 +131,7 @@ export default { this.structureLoadingState = 'error'; return; } - if (this.context.type === 'courses') { - await this.loadTeacherStatus(this.userId); - } + this.structureLoadingState = 'done'; const selectedId = this.$route.params?.id; await this.selectStructuralElement(selectedId); diff --git a/resources/vue/components/courseware/ShelfApp.vue b/resources/vue/components/courseware/ShelfApp.vue new file mode 100644 index 0000000000000000000000000000000000000000..e9f275d8d96d6e5be15b70fe4a4fed061ee5bd97 --- /dev/null +++ b/resources/vue/components/courseware/ShelfApp.vue @@ -0,0 +1,65 @@ +<template> + <div> + <div class="cw-shelf"> + <courseware-unit-items /> + </div> + <courseware-shelf-dialog-add v-if="showUnitAddDialog" /> + <courseware-shelf-dialog-copy v-if="showUnitCopyDialog" /> + <courseware-shelf-dialog-import v-if="showUnitImportDialog" /> + <MountingPortal v-if="userIsTeacher || !inCourseContext" mountTo="#courseware-action-widget" name="sidebar-actions"> + <courseware-shelf-action-widget></courseware-shelf-action-widget> + </MountingPortal> + <MountingPortal v-if="userIsTeacher || !inCourseContext" mountTo="#courseware-import-widget" name="sidebar-imports"> + <courseware-shelf-import-widget></courseware-shelf-import-widget> + </MountingPortal> + <courseware-companion-overlay /> + </div> +</template> + +<script> +import CoursewareShelfActionWidget from './CoursewareShelfActionWidget.vue'; +import CoursewareShelfImportWidget from './CoursewareShelfImportWidget.vue'; +import CoursewareShelfDialogAdd from './CoursewareShelfDialogAdd.vue'; +import CoursewareShelfDialogCopy from './CoursewareShelfDialogCopy.vue'; +import CoursewareShelfDialogImport from './CoursewareShelfDialogImport.vue'; +import CoursewareUnitItems from './CoursewareUnitItems.vue'; +import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; + +import { mapActions, mapGetters } from 'vuex'; + +export default { + components: { + CoursewareShelfActionWidget, + CoursewareShelfImportWidget, + CoursewareShelfDialogAdd, + CoursewareShelfDialogCopy, + CoursewareShelfDialogImport, + CoursewareUnitItems, + CoursewareCompanionOverlay, + }, + computed: { + ...mapGetters({ + showUnitAddDialog: 'showUnitAddDialog', + showUnitCopyDialog: 'showUnitCopyDialog', + showUnitImportDialog: 'showUnitImportDialog', + showUnitLinkDialog: 'showUnitLinkDialog', + licenses: 'licenses', + context:'context', + userIsTeacher: 'userIsTeacher', + userId: 'userId' + }), + inCourseContext() { + return this.context.type === 'courses'; + } + + }, + methods: { + ...mapActions({ + setShowUnitAddDialog: 'setShowUnitAddDialog', + setShowUnitCopyDialog: 'setShowUnitCopyDialog', + setShowUnitImportDialog: 'setShowUnitImportDialog', + setShowUnitLinkDialog: 'setShowUnitLinkDialog', + }), + }, +} +</script> diff --git a/resources/vue/components/courseware/TasksApp.vue b/resources/vue/components/courseware/TasksApp.vue new file mode 100644 index 0000000000000000000000000000000000000000..37ec927542c4b180078cf5e123b5822f9e7012c2 --- /dev/null +++ b/resources/vue/components/courseware/TasksApp.vue @@ -0,0 +1,31 @@ +<template> + <div class="cw-tasks-wrapper"> + <div class="cw-tasks-list"> + <courseware-dashboard-students v-if="userIsTeacher" /> + <courseware-dashboard-tasks v-else /> + </div> + <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher"> + <courseware-tasks-action-widget /> + </MountingPortal> + </div> +</template> + +<script> +import CoursewareTasksActionWidget from './CoursewareTasksActionWidget.vue'; +import CoursewareDashboardTasks from './CoursewareDashboardTasks.vue' +import CoursewareDashboardStudents from './CoursewareDashboardStudents.vue' +import { mapGetters } from 'vuex'; + +export default { + components: { + CoursewareTasksActionWidget, + CoursewareDashboardTasks, + CoursewareDashboardStudents, + }, + computed: { + ...mapGetters({ + userIsTeacher: 'userIsTeacher', + }), + }, +}; +</script> diff --git a/resources/vue/courseware-content-overview-app.js b/resources/vue/courseware-activities-app.js similarity index 64% rename from resources/vue/courseware-content-overview-app.js rename to resources/vue/courseware-activities-app.js index ccacce861743974af031b67fb8f9fb100e70b917..ee2eb849de248dc3e20851e1a6eaca4b964a85a6 100644 --- a/resources/vue/courseware-content-overview-app.js +++ b/resources/vue/courseware-activities-app.js @@ -1,23 +1,19 @@ -import ContentOverviewApp from './components/courseware/ContentOverviewApp.vue'; -import CoursewareStructureModule from './store/courseware/structure.module'; +import ActivitiesApp from './components/courseware/ActivitiesApp.vue'; import { mapResourceModules } from '@elan-ev/reststate-vuex'; -import Vue from 'vue'; import Vuex from 'vuex'; import CoursewareModule from './store/courseware/courseware.module'; +import CoursewareActivitiesModule from './store/courseware/courseware-activities.module'; +import CoursewareStructureModule from './store/courseware/structure.module'; import axios from 'axios'; -import vSelect from 'vue-select'; -import 'vue-select/dist/vue-select.css' -Vue.component('v-select', vSelect); - -const mountApp = (STUDIP, createApp, element) => { +const mountApp = async (STUDIP, createApp, element) => { const getHttpClient = () => - axios.create({ - baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true), - headers: { - 'Content-Type': 'application/vnd.api+json', - }, - }); + axios.create({ + baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true), + headers: { + 'Content-Type': 'application/vnd.api+json', + }, + }); const httpClient = getHttpClient(); @@ -25,6 +21,7 @@ const mountApp = (STUDIP, createApp, element) => { modules: { courseware: CoursewareModule, 'courseware-structure': CoursewareStructureModule, + 'courseware-activities': CoursewareActivitiesModule, ...mapResourceModules({ names: [ 'activities', @@ -37,11 +34,15 @@ const mountApp = (STUDIP, createApp, element) => { 'courseware-containers', 'courseware-instances', 'courseware-structural-elements', - 'courseware-structural-elements-shared', - 'courseware-templates', + 'courseware-task-feedback', + 'courseware-task-groups', + 'courseware-tasks', + 'courseware-units', 'courseware-user-data-fields', 'courseware-user-progresses', + 'files', 'file-refs', + 'folders', 'users', 'institutes', 'semesters', @@ -55,7 +56,6 @@ const mountApp = (STUDIP, createApp, element) => { }); let entry_id = null; let entry_type = null; - let licenses = null; let elem; if ((elem = document.getElementById(element.substring(1))) !== undefined) { @@ -67,34 +67,27 @@ const mountApp = (STUDIP, createApp, element) => { if (elem.attributes['entry-id'] !== undefined) { entry_id = elem.attributes['entry-id'].value; } - - if (elem.attributes['licenses'] !== undefined) { - licenses = JSON.parse(elem.attributes['licenses'].value); - } } } store.dispatch('setUserId', STUDIP.USER_ID); - store.dispatch('users/loadById', {id: STUDIP.USER_ID}); - store.dispatch('courseware-structural-elements/loadById',{ id: STUDIP.COURSEWARE_USERS_ROOT_ID, options: { include: 'children'}}); - store.dispatch('courseware-templates/loadAll'); + await store.dispatch('users/loadById', {id: STUDIP.USER_ID}); store.dispatch('setHttpClient', httpClient); - store.dispatch('licenses', licenses); store.dispatch('coursewareContext', { id: entry_id, type: entry_type, }); - - store.dispatch('courseware-structural-elements-shared/loadAll', { options: { include: 'owner' } }); + await store.dispatch('loadTeacherStatus', STUDIP.USER_ID); + await store.dispatch('loadCourseUnits', entry_id); const app = createApp({ - render: (h) => h(ContentOverviewApp), - store + render: (h) => h(ActivitiesApp), + store, }); app.$mount(element); return app; -} +}; export default mountApp; diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index e39d053b966a6902eaf83a3d326603a80a5302c6..57a8936da1bd11b6297308a0b512d5ed1de24ad0 100644 --- a/resources/vue/courseware-index-app.js +++ b/resources/vue/courseware-index-app.js @@ -22,6 +22,7 @@ const mountApp = async (STUDIP, createApp, element) => { let elem_id = null; let entry_id = null; let entry_type = null; + let unit_id = null; let oer_enabled = null; let licenses = null; let elem; @@ -40,6 +41,10 @@ const mountApp = async (STUDIP, createApp, element) => { entry_id = elem.attributes['entry-id'].value; } + if (elem.attributes['unit-id'] !== undefined) { + unit_id = elem.attributes['unit-id'].value; + } + if (elem.attributes['oer-enabled'] !== undefined) { oer_enabled = elem.attributes['oer-enabled'].value; } @@ -49,7 +54,6 @@ const mountApp = async (STUDIP, createApp, element) => { } } } - const routes = [ { path: '/', @@ -97,8 +101,10 @@ const mountApp = async (STUDIP, createApp, element) => { 'courseware-task-feedback', 'courseware-task-groups', 'courseware-tasks', + 'courseware-templates', 'courseware-user-data-fields', 'courseware-user-progresses', + 'courseware-units', 'files', 'file-refs', 'folders', @@ -124,12 +130,18 @@ const mountApp = async (STUDIP, createApp, element) => { store.dispatch('coursewareContext', { id: entry_id, type: entry_type, + unit: unit_id }); + if (entry_type === 'courses') { + await store.dispatch('loadTeacherStatus', STUDIP.USER_ID); + } + store.dispatch('coursewareCurrentElement', elem_id); store.dispatch('oerEnabled', oer_enabled); store.dispatch('licenses', licenses); + store.dispatch('courseware-templates/loadAll'); const pluginManager = new PluginManager(); store.dispatch('setPluginManager', pluginManager); diff --git a/resources/vue/courseware-shelf-app.js b/resources/vue/courseware-shelf-app.js new file mode 100644 index 0000000000000000000000000000000000000000..2d31aef10dfc6ba76e17fc2cf132224c90c1afe7 --- /dev/null +++ b/resources/vue/courseware-shelf-app.js @@ -0,0 +1,93 @@ +import CoursewareShelfModule from './store/courseware/courseware-shelf.module'; +import ShelfApp from './components/courseware/ShelfApp.vue'; +import Vuex from 'vuex'; +import axios from 'axios'; +import { mapResourceModules } from '@elan-ev/reststate-vuex'; + +const mountApp = async (STUDIP, createApp, element) => { + const getHttpClient = () => + axios.create({ + baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true), + headers: { + 'Content-Type': 'application/vnd.api+json', + }, + }); + + let elem; + let entry_id = null; + let entry_type = null; + let licenses = null; + + if ((elem = document.getElementById(element.substring(1))) !== undefined) { + if (elem.attributes !== undefined) { + if (elem.attributes['entry-type'] !== undefined) { + entry_type = elem.attributes['entry-type'].value; + } + + if (elem.attributes['entry-id'] !== undefined) { + entry_id = elem.attributes['entry-id'].value; + } + + if (elem.attributes['licenses'] !== undefined) { + licenses = JSON.parse(elem.attributes['licenses'].value); + } + } + } + + const httpClient = getHttpClient(); + + const store = new Vuex.Store({ + modules: { + 'courseware-shelf': CoursewareShelfModule, + ...mapResourceModules({ + names: [ + 'courses', + 'course-memberships', + 'courseware-blocks', + 'courseware-containers', + 'courseware-instances', + 'courseware-units', + 'courseware-user-data-fields', + 'courseware-user-progresses', + 'courseware-structural-elements', + 'files', + 'file-refs', + 'folders', + 'users', + 'institutes', + 'institute-memberships', + 'semesters', + 'sem-classes', + 'sem-types', + 'terms-of-use' + ], + httpClient, + }), + }, + }); + store.dispatch('setUrlHelper', STUDIP.URLHelper); + store.dispatch('setHttpClient', httpClient); + store.dispatch('setLicenses', licenses); + store.dispatch('setUserId', STUDIP.USER_ID); + await store.dispatch('users/loadById', {id: STUDIP.USER_ID}); + store.dispatch('setContext', { + id: entry_id, + type: entry_type, + }); + if (entry_type === 'courses') { + await store.dispatch('loadTeacherStatus', STUDIP.USER_ID); + await store.dispatch('loadCourseUnits', entry_id); + } else { + await store.dispatch('loadUserUnits', entry_id); + } + + const app = createApp({ + render: (h) => h(ShelfApp), + store, + }); + + app.$mount(element); + +}; + +export default mountApp; \ No newline at end of file diff --git a/resources/vue/courseware-dashboard-app.js b/resources/vue/courseware-tasks-app.js similarity index 88% rename from resources/vue/courseware-dashboard-app.js rename to resources/vue/courseware-tasks-app.js index 5ddaf344ff17d2edce1784c156e589122c513829..2f332466d796aba116efa98372a04ae9ba3883a9 100644 --- a/resources/vue/courseware-dashboard-app.js +++ b/resources/vue/courseware-tasks-app.js @@ -1,7 +1,8 @@ -import DashboardApp from './components/courseware/DashboardApp.vue'; +import TasksApp from './components/courseware/TasksApp.vue'; import { mapResourceModules } from '@elan-ev/reststate-vuex'; import Vuex from 'vuex'; import CoursewareModule from './store/courseware/courseware.module'; +import CoursewareTasksModule from './store/courseware/courseware-tasks.module'; import CoursewareStructureModule from './store/courseware/structure.module'; import axios from 'axios'; @@ -19,6 +20,7 @@ const mountApp = async (STUDIP, createApp, element) => { const store = new Vuex.Store({ modules: { courseware: CoursewareModule, + tasks: CoursewareTasksModule, 'courseware-structure': CoursewareStructureModule, ...mapResourceModules({ names: [ @@ -35,6 +37,7 @@ const mountApp = async (STUDIP, createApp, element) => { 'courseware-task-feedback', 'courseware-task-groups', 'courseware-tasks', + 'courseware-units', 'courseware-user-data-fields', 'courseware-user-progresses', 'files', @@ -42,7 +45,6 @@ const mountApp = async (STUDIP, createApp, element) => { 'folders', 'users', 'institutes', - 'institute-memberships', 'semesters', 'sem-classes', 'sem-types', @@ -69,12 +71,13 @@ const mountApp = async (STUDIP, createApp, element) => { } store.dispatch('setUserId', STUDIP.USER_ID); - await store.dispatch('users/loadById', { id: STUDIP.USER_ID }); + await store.dispatch('users/loadById', {id: STUDIP.USER_ID}); store.dispatch('setHttpClient', httpClient); store.dispatch('coursewareContext', { id: entry_id, type: entry_type, }); + await store.dispatch('loadTeacherStatus', STUDIP.USER_ID); store.dispatch('courseware-tasks/loadAll', { options: { 'filter[cid]': entry_id, @@ -83,7 +86,7 @@ const mountApp = async (STUDIP, createApp, element) => { }); const app = createApp({ - render: (h) => h(DashboardApp), + render: (h) => h(TasksApp), store, }); diff --git a/resources/vue/mixins/courseware/colors.js b/resources/vue/mixins/courseware/colors.js new file mode 100644 index 0000000000000000000000000000000000000000..870ae7cc30132132334201b02965b3c14de875f2 --- /dev/null +++ b/resources/vue/mixins/courseware/colors.js @@ -0,0 +1,149 @@ +const colorMixin = { + computed: { + mixinColors() { + const colors = [ + { + name: this.$gettext('Schwarz'), + class: 'black', + hex: '#000000', + level: 100, + icon: 'black', + darkmode: true, + }, + { + name: this.$gettext('Weiß'), + class: 'white', + hex: '#ffffff', + level: 100, + icon: 'white', + darkmode: false, + }, + + { + name: this.$gettext('Blau'), + class: 'studip-blue', + hex: '#28497c', + level: 100, + icon: 'blue', + darkmode: true, + }, + { + name: this.$gettext('Hellblau'), + class: 'studip-lightblue', + hex: '#e7ebf1', + level: 40, + icon: 'lightblue', + darkmode: false, + }, + { + name: this.$gettext('Rot'), + class: 'studip-red', + hex: '#d60000', + level: 100, + icon: 'red', + darkmode: false, + }, + { + name: this.$gettext('Grün'), + class: 'studip-green', + hex: '#008512', + level: 100, + icon: 'green', + darkmode: true, + }, + { + name: this.$gettext('Gelb'), + class: 'studip-yellow', + hex: '#ffbd33', + level: 100, + icon: 'yellow', + darkmode: false, + }, + { + name: this.$gettext('Grau'), + class: 'studip-gray', + hex: '#636a71', + level: 100, + icon: 'grey', + darkmode: true, + }, + + { + name: this.$gettext('Holzkohle'), + class: 'charcoal', + hex: '#3c454e', + level: 100, + icon: false, + darkmode: true, + }, + { + name: this.$gettext('Königliches Purpur'), + class: 'royal-purple', + hex: '#8656a2', + level: 80, + icon: false, + darkmode: true, + }, + { + name: this.$gettext('Leguangrün'), + class: 'iguana-green', + hex: '#66b570', + level: 60, + icon: false, + darkmode: true, + }, + { + name: this.$gettext('Königin blau'), + class: 'queen-blue', + hex: '#536d96', + level: 80, + icon: false, + darkmode: true, + }, + { + name: this.$gettext('Helles Seegrün'), + class: 'verdigris', + hex: '#41afaa', + level: 80, + icon: false, + darkmode: true, + }, + { + name: this.$gettext('Maulbeere'), + class: 'mulberry', + hex: '#bf5796', + level: 80, + icon: false, + darkmode: true, + }, + { + name: this.$gettext('Kürbis'), + class: 'pumpkin', + hex: '#f26e00', + level: 100, + icon: false, + darkmode: true, + }, + { + name: this.$gettext('Sonnenschein'), + class: 'sunglow', + hex: '#ffca5c', + level: 80, + icon: false, + darkmode: false, + }, + { + name: this.$gettext('Apfelgrün'), + class: 'apple-green', + hex: '#8bbd40', + level: 80, + icon: false, + darkmode: true, + }, + ]; + return colors; + } + }, +}; + +export default colorMixin; \ No newline at end of file diff --git a/resources/vue/mixins/courseware/export.js b/resources/vue/mixins/courseware/export.js index dd0b5229d0ad82fb083466663880092f6ea9350b..bdd0a3b0cddbc699d2d6093fa08b94afc325153c 100644 --- a/resources/vue/mixins/courseware/export.js +++ b/resources/vue/mixins/courseware/export.js @@ -11,7 +11,10 @@ export default { containerById: 'courseware-containers/byId', folderById: 'folders/byId', filesById: 'files/byId', + fileRefsById: 'file-refs/byId', structuralElementById: 'courseware-structural-elements/byId', + allStructuralElements: 'courseware-structural-elements/all', + allBlocks: 'courseware-blocks/all', }), }, @@ -48,11 +51,13 @@ export default { }, async createExportFile(root_id = null, options) { - let completeExport = false; + if (!options || !options.completeExport) { + options.completeExport = false; + } if (!root_id) { root_id = this.courseware.relationships.root.data.id; - completeExport = true; + options.completeExport = true; } this.setExportState(this.$gettext('Exportiere Elemente')); this.setExportProgress(0); @@ -62,7 +67,7 @@ export default { zip.file('courseware.json', JSON.stringify(exportData.json)); zip.file('files.json', JSON.stringify(exportData.files.json)); - if (completeExport) { + if (options.completeExport) { zip.file('settings.json', JSON.stringify(exportData.settings)); } @@ -93,14 +98,14 @@ export default { if (options && options.withChildren === true) { withChildren = true; } - + await this.loadStructuralElement(root_id); let root_element = await this.structuralElementById({id: root_id}); //prevent loss of data root_element = JSON.parse(JSON.stringify(root_element)); // load whole courseware nonetheless, only export relevant elements - let elements = await this.$store.getters['courseware-structural-elements/all']; + let elements = await this.allStructuralElements; this.exportElementCounter = 0; if (withChildren) { this.elementCounter = this.countElements(elements); @@ -135,9 +140,21 @@ export default { delete root_element.links; let settings = { - 'editing-permission-level': this.courseware.attributes['editing-permission-level'], - 'sequential-progression': this.courseware.attributes['sequential-progression'] + 'editing-permission-level': 'tutor', + 'sequential-progression': '0' }; + if (this.courseware != null) { + settings = { + 'editing-permission-level': this.courseware.attributes['editing-permission-level'], + 'sequential-progression': this.courseware.attributes['sequential-progression'] + }; + } + if (options && options.settings) { + settings = { + 'editing-permission-level': options.settings['editing-permission-level'], + 'sequential-progression': options.settings['sequential-progression'] + }; + } return { json: root_element, @@ -189,7 +206,6 @@ export default { this.companionInfo({ info: this.$gettext('Die Seite wurde an den OER Campus gesendet.') }); }).catch(error => { this.companionError({ info: this.$gettext('Beim Veröffentlichen der Seite ist ein Fehler aufgetreten.') }); - console.debug(error); }); }, @@ -198,15 +214,13 @@ export default { for (var i = 0; i < data.length; i++) { if (data[i].relationships.parent.data?.id === parentId && data[i].attributes['can-edit']) { + const content = { ...data[i] }; + await this.loadStructuralElement(content.id); let new_childs = await this.exportStructuralElement(data[i].id, data); this.exportElementCounter++; - let content = { ...data[i] }; content.containers = []; - await this.loadStructuralElement(content.id); - let element = this.structuralElementById({ id: content.id }); - // load containers, if there are any for this struct if (element.relationships.containers?.data?.length) { for (var j = 0; j < element.relationships.containers.data.length; j++) { @@ -237,9 +251,9 @@ export default { async exportStructuralElementImage(element) { let fileId = element.relationships.image?.data?.id; if (fileId) { - await this.$store.dispatch('file-refs/loadById', {id: fileId}); - let fileRef = this.$store.getters['file-refs/byId']({id: fileId}); - + await this.loadFileRefsById({id: fileId}); + let fileRef = this.fileRefsById({id: fileId}); + let fileRefData = {}; fileRefData.id = fileRef.id; fileRefData.attributes = fileRef.attributes; @@ -262,7 +276,7 @@ export default { container.blocks = []; - let blocks = this.$store.getters['courseware-blocks/all']; + let blocks = this.allBlocks; // now, load the blocks for this container, if there are any if (blocks.length) { @@ -358,14 +372,15 @@ export default { } }, - ...mapActions([ - 'loadStructuralElement', - 'loadFileRefs', - 'loadFolder', - 'companionInfo', - 'setExportState', - 'setExportProgress' - ]), + ...mapActions({ + loadStructuralElement: 'loadStructuralElement', + loadFileRefs: 'loadFileRefs', + loadFolder: 'loadFolder', + companionInfo: 'companionInfo', + setExportState: 'setExportState', + setExportProgress: 'setExportProgress', + loadFileRefsById: 'file-refs/loadById' + }), }, watch: { exportElementCounter(counter) { diff --git a/resources/vue/mixins/courseware/import.js b/resources/vue/mixins/courseware/import.js index 100a0e5bca7d1e031838081e93962de1bec15faa..4b46ab89daee258410b13476084a8a25f9b93018 100644 --- a/resources/vue/mixins/courseware/import.js +++ b/resources/vue/mixins/courseware/import.js @@ -9,6 +9,7 @@ export default { elementCounter: 0, importElementCounter: 0, currentImportErrors: [], + unitId: null, }; }, @@ -16,8 +17,26 @@ export default { ...mapGetters({ context: 'context', courseware: 'courseware-instances/all', + instanceById: 'courseware-instances/byId', + lastCreatedBlocks: 'courseware-blocks/lastCreated', + lastCreatedContainers: 'courseware-containers/lastCreated', + lastCreatedElements: 'courseware-structural-elements/lastCreated', structuralElementById: 'courseware-structural-elements/byId', }), + instance() { + if (this.unitId) { + if (this.inCourseContext) { + return this.instanceById({id: 'course_' + this.context.id + '_' + this.unitId}); + } else { + return this.instanceById({id: 'user_' + this.context.id + '_' + this.unitId}); + } + } else { + return null; + } + }, + inCourseContext() { + return this.context.type === 'courses'; + } }, methods: { @@ -26,7 +45,7 @@ export default { updateStructuralElement: 'updateStructuralElement' }), - async importCourseware(element, rootId, files, importBehavior) + async importCourseware(element, rootId, files, importBehavior, settings) { // import all files await this.uploadAllFiles(files); @@ -40,7 +59,7 @@ export default { await this.importStructuralElement([element], rootId, files); } if (importBehavior === 'migrate') { - await this.migrateCourseware(element, rootId, files); + await this.migrateCourseware(element, rootId, files, settings); } }, @@ -70,8 +89,20 @@ export default { return counter; }, - async migrateCourseware(element, rootId, files) { + async migrateCourseware(element, rootId, files, settings) { let root = this.structuralElementById({ id: rootId }); + + if (settings) { + this.unitId = root.relationships.unit.data.id; + const context = {type: this.context.type, id: this.context.id, unit: this.unitId}; + await this.loadInstance(context); + let currentInstance = this.instance; + currentInstance.attributes['editing-permission-level'] = settings['editing-permission-level']; + currentInstance.attributes['sequential-progression'] = settings['sequential-progression']; + this.storeCoursewareSettings({ + instance: currentInstance, + }); + } // add containers and blocks if (element.containers?.length > 0) { await Promise.all(element.containers.map((container) => this.importContainer(container, root, files))); @@ -152,7 +183,7 @@ export default { this.importElementCounter++; - let new_element = this.$store.getters['courseware-structural-elements/lastCreated']; + let new_element = this.lastCreatedElements; if (element[i].imageId) { await this.setStructuralElementImage(new_element, element[i].imageId, files); @@ -205,7 +236,7 @@ export default { } this.importElementCounter++; - let new_container = this.$store.getters['courseware-containers/lastCreated']; + let new_container = this.lastCreatedContainers; await this.unlockObject({ id: new_container.id, type: 'courseware-containers' }); if (container.blocks?.length) { @@ -242,7 +273,7 @@ export default { return null; } - let new_block = this.$store.getters['courseware-blocks/lastCreated']; + let new_block = this.lastCreatedBlocks; // update old id ids in payload part for (var i = 0; i < files.length; i++) { @@ -394,6 +425,7 @@ export default { 'createFolder', 'createRootFolder', 'createFile', + 'loadInstance', 'lockObject', 'unlockObject', 'setImportFilesState', @@ -401,6 +433,7 @@ export default { 'setImportStructuresState', 'setImportStructuresProgress', 'setImportErrors', + 'storeCoursewareSettings', 'uploadImageForStructuralElement' ]), }, diff --git a/resources/vue/store/courseware/courseware-activities.module.js b/resources/vue/store/courseware/courseware-activities.module.js new file mode 100644 index 0000000000000000000000000000000000000000..2b96193f27ac4e6143a5dfde897c60d7d6ff5bc8 --- /dev/null +++ b/resources/vue/store/courseware/courseware-activities.module.js @@ -0,0 +1,49 @@ +const getDefaultState = () => { + return { + typeFilter: 'all', + unitFilter: 'all', + }; +}; + +const initialState = getDefaultState(); + +const getters = { + typeFilter(state) { + return state.typeFilter; + }, + unitFilter(state) { + return state.unitFilter; + }, +}; + +export const state = { ...initialState }; + +export const actions = { + // setters + setTypeFilter({ commit }, context) { + commit('setTypeFilter', context); + }, + + setUnitFilter({ commit }, context) { + commit('setUnitFilter', context); + }, + + // other actions +}; + +export const mutations = { + setTypeFilter(state, data){ + state.typeFilter = data; + }, + + setUnitFilter(state, data){ + state.unitFilter = data; + }, +}; + +export default { + state, + actions, + mutations, + getters, +}; diff --git a/resources/vue/store/courseware/courseware-shelf.module.js b/resources/vue/store/courseware/courseware-shelf.module.js new file mode 100644 index 0000000000000000000000000000000000000000..1b91ff48f78ef386948494a3dee473974f582c41 --- /dev/null +++ b/resources/vue/store/courseware/courseware-shelf.module.js @@ -0,0 +1,766 @@ +const getDefaultState = () => { + return { + context: null, + httpClient: null, + showCompanionOverlay: false, + msgCompanionOverlay: '', + styleCompanionOverlay: 'default', + showToolbar: false, + showUnitAddDialog: false, + showUnitCopyDialog: false, + showUnitImportDialog: false, + showUnitLinkDialog: false, + licenses: null, + userId: null, + exportState: '', + exportProgress: 0, + courseware: null, + userIsTeacher: false, + urlHelper: null, + + importFilesState: '', + importFilesProgress: 0, + importStructuresState: '', + importStructuresProgress: 0, + importErrors: [], + }; +}; + +const initialState = getDefaultState(); + +const getters = { + context(state) { + return state.context; + }, + httpClient(state) { + return state.httpClient; + }, + showCompanionOverlay(state) { + return state.showCompanionOverlay; + }, + msgCompanionOverlay(state) { + return state.msgCompanionOverlay; + }, + styleCompanionOverlay(state) { + return state.styleCompanionOverlay; + }, + showToolbar(state) { + return state.showToolbar; + }, + showUnitAddDialog(state) { + return state.showUnitAddDialog; + }, + showUnitCopyDialog(state) { + return state.showUnitCopyDialog; + }, + showUnitImportDialog(state) { + return state.showUnitImportDialog; + }, + showUnitLinkDialog(state) { + return state.showUnitLinkDialog; + }, + licenses(state) { + return state.licenses; + }, + userId(state) { + return state.userId; + }, + courseware(state) { + return state.courseware; + }, + exportState(state) { + return state.exportState; + }, + exportProgress(state) { + return state.exportProgress; + }, + userIsTeacher(state) { + return state.userIsTeacher; + }, + urlHelper(state) { + return state.urlHelper; + }, + importFilesState(state) { + return state.importFilesState; + }, + importFilesProgress(state) { + return state.importFilesProgress; + }, + importStructuresState(state) { + return state.importStructuresState; + }, + importStructuresProgress(state) { + return state.importStructuresProgress; + }, + importErrors(state) { + return state.importErrors; + }, +}; + +export const state = { ...initialState }; + +export const actions = { + // setters + setContext({ commit }, context) { + commit('setContext', context); + }, + setHttpClient({ commit }, httpClient) { + commit('setHttpClient', httpClient); + }, + setShowUnitAddDialog({ commit }, show) { + commit('setShowUnitAddDialog', show); + }, + setShowUnitCopyDialog({ commit }, show) { + commit('setShowUnitCopyDialog', show); + }, + setShowUnitImportDialog({ commit }, show) { + commit('setShowUnitImportDialog', show); + }, + setShowUnitLinkDialog({ commit }, show) { + commit('setShowUnitLinkDialog', show); + }, + setLicenses({ commit }, licenses) { + commit('setLicenses', licenses); + }, + setShowCompanionOverlay(context, companionOverlay) { + context.commit('setShowCompanionOverlay', companionOverlay); + }, + setMsgCompanionOverlay(context, companionOverlayMsg) { + context.commit('setMsgCompanionOverlay', companionOverlayMsg); + }, + setStyleCompanionOverlay(context, companionOverlayStyle) { + context.commit('setStyleCompanionOverlay', companionOverlayStyle); + }, + setUserId(context, id) { + context.commit('setUserId', id); + }, + setExportState(context, state) { + context.commit('setExportState', state); + }, + setExportProgress(context, percent) { + context.commit('setExportProgress', percent); + }, + setUrlHelper(context, urlHelper) { + context.commit('setUrlHelper', urlHelper); + }, + + // other actions + loadCourseUnits({ dispatch }, cid) { + const parent = { type: 'courses', id: cid }; + const relationship = 'courseware-units'; + const options = { include: 'structural-element' } + + return dispatch('loadRelatedPaginated', { + type: 'courseware-units', + parent, + relationship, + options, + }); + }, + + loadUserUnits({ dispatch }, uid) { + const parent = { type: 'users', id: uid }; + const relationship = 'courseware-units'; + const options = { include: 'structural-element' } + + return dispatch('loadRelatedPaginated', { + type: 'courseware-units', + parent, + relationship, + options, + }); + }, + + async loadUnitProgresses({ getters }, { unitId }) { + const response = await state.httpClient.get(`courseware-units/${unitId}/courseware-user-progresses`); + if (response.status === 200) { + return response.data; + } else { + return null; + } + }, + + async loadRelatedPaginated({ dispatch, rootGetters }, { type, parent, relationship, options }) { + const limit = 100; + let offset = 0; + + await loadPage(offset, limit); + const total = rootGetters[`${type}/lastMeta`].page.total; + + const pages = []; + for (let page = 1; page * limit < total; page++) { + pages.push(loadPage(page * limit, limit)); + } + + return Promise.all(pages); + + function loadPage(offset, limit) { + return dispatch( + `${type}/loadRelated`, + { + parent, + relationship, + options: { + ...options, + 'page[offset]': offset, + 'page[limit]': limit, + }, + resetRelated: false, + }, + { root: true } + ) + } + }, + async companionInfo({ dispatch }, { info }) { + await dispatch('setStyleCompanionOverlay', 'default'); + await dispatch('setMsgCompanionOverlay', info); + return dispatch('setShowCompanionOverlay', true); + }, + + async companionSuccess({ dispatch }, { info }) { + await dispatch('setStyleCompanionOverlay', 'happy'); + await dispatch('setMsgCompanionOverlay', info); + return dispatch('setShowCompanionOverlay', true); + }, + + async companionError({ dispatch }, { info }) { + await dispatch('setStyleCompanionOverlay', 'sad'); + await dispatch('setMsgCompanionOverlay', info); + return dispatch('setShowCompanionOverlay', true); + }, + + async companionWarning({ dispatch }, { info }) { + await dispatch('setStyleCompanionOverlay', 'alert'); + await dispatch('setMsgCompanionOverlay', info); + return dispatch('setShowCompanionOverlay', true); + }, + + async companionSpecial({ dispatch }, { info }) { + await dispatch('setStyleCompanionOverlay', 'special'); + await dispatch('setMsgCompanionOverlay', info); + return dispatch('setShowCompanionOverlay', true); + }, + coursewareShowCompanionOverlay({dispatch}, { data }) { + return dispatch('setShowCompanionOverlay', data); + }, + + async deleteUnit({ dispatch, state }, data) { + await dispatch('courseware-units/delete', data, { root: true }); + if (state.context.type === 'courses') { + return dispatch('loadCourseUnits', state.context.id); + } + if (state.context.type === 'users') { + return dispatch('loadUserUnits', state.context.id); + } + }, + + async copyUnit({ dispatch, state }, { unitId, modified }) { + let rangeType = null; + let loadUnits = null; + if (state.context.type === 'courses') { + rangeType = 'course'; + loadUnits = 'loadCourseUnits'; + } + if (state.context.type === 'users') { + rangeType = 'user'; + loadUnits = 'loadUserUnits'; + } + if(!rangeType) { + return false; + } + const copy = { data: { rangeId: state.context.id, rangeType: rangeType, modified: modified } }; + await state.httpClient.post(`courseware-units/${unitId}/copy`, copy); + + return dispatch(loadUnits, state.context.id); + }, + + async loadUsersCourses({ dispatch, rootGetters, state }, { userId, withCourseware }) { + const parent = { + type: 'users', + id: userId, + }; + const relationship = 'course-memberships'; + const options = { + include: 'course', + }; + await dispatch('loadRelatedPaginated', { + type: 'course-memberships', + parent, + relationship, + options, + }); + + const memberships = rootGetters['course-memberships/related']({ + parent, + relationship, + }); + + const otherMemberships = memberships.filter(({ attributes, relationships }) => { + return ['dozent', 'tutor'].includes(attributes.permission) && state.context.id !== relationships.course.data.id; + }); + + if (!withCourseware) { + return otherMemberships.map((membership) => { + return getCourse(membership); + }); + } + + const items = await Promise.all( + otherMemberships.map((membership) => { + const course = getCourse(membership); + + return dispatch('loadRemoteCoursewareStructure', { + rangeId: course.id, + rangeType: course.type + }).then((instance) => ({ instance, membership, course })); + }) + ) + + return items + .filter(({ instance, membership }) => { + return instance?.relationships?.root && (membership.attributes.permission === 'dozent' || instance.attributes['editing-permission-level'] === 'tutor'); + }) + .map(({ course }) => course); + + function getCourse(membership) { + return rootGetters['courses/related']({ parent: membership, relationship: 'course' }); + } + }, + + async loadRemoteCoursewareStructure({ dispatch, rootGetters }, { rangeId, rangeType }) { + const parent = { + id: rangeId, + type: rangeType, + }; + + const relationship = 'courseware'; + + return dispatch(`courseware-instances/loadRelated`, { parent, relationship }, { root: true }).then( + (response) => { + const instance = rootGetters['courseware-instances/related']({ + parent: parent, + relationship: relationship, + }); + + return instance; + }, + (error) => { + return null; + } + ); + }, + loadInstance({ commit, dispatch, rootGetters }, context) { + const parent = { + type: context.type, + id: context.id + '_' + context.unit + } + + const relationship = 'courseware'; + const options = {}; + + return dispatch( + `courseware-instances/loadRelated`, + { + parent, + relationship, + options, + }, + { root: true } + ).then(() => { + return rootGetters['courseware-instances/related']({ parent, relationship }); + }); + }, + + loadStructuralElement({ dispatch }, structuralElementId) { + const options = { + include: + 'children,containers,containers.edit-blocker,containers.blocks,containers.blocks.editor,containers.blocks.owner,containers.blocks.edit-blocker,editor,edit-blocker,owner', + 'fields[users]': 'formatted-name', + }; + + return dispatch( + 'courseware-structural-elements/loadById', + { id: structuralElementId, options }, + { root: true } + ); + }, + + loadContainer({ dispatch }, containerId) { + const options = { + include: 'blocks,blocks.edit-blocker','fields[users]': 'formatted-name', + }; + + return dispatch('courseware-containers/loadById', { id: containerId, options }, { root: true }); + }, + + loadFileRefs({ dispatch, rootGetters }, block_id) { + const parent = { + type: 'courseware-blocks', + id: block_id, + }; + const relationship = 'file-refs'; + + return dispatch('file-refs/loadRelated', { parent, relationship }, { root: true }).then(() => + rootGetters['file-refs/related']({ + parent, + relationship, + }) + ); + }, + + async createFile(context, { file, filedata, folder }) { + const termId = file?.relationships['terms-of-use']?.data?.id ?? null; + const formData = new FormData(); + formData.append('file', filedata, file.attributes.name); + if (termId) { + formData.append('term-id', termId); + } + const url = `folders/${folder.id}/file-refs`; + let request = await state.httpClient.post(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + let response = null; + try { + response = await state.httpClient.get(request.headers.location); + } + catch(e) { + console.debug(e); + response = null; + } + + return response ? response.data.data : response; + }, + + async createRootFolder({ dispatch, rootGetters }, { context, folder }) { + // get root folder for this context + await dispatch( + `${context.type}/loadRelated`, + { + parent: context, + relationship: 'folders', + }, + { root: true } + ); + + let folders = await rootGetters[`${context.type}/related`]({ + parent: context, + relationship: 'folders', + }); + + let rootFolder = null; + + for (let i = 0; i < folders.length; i++) { + if (folders[i].attributes['folder-type'] === 'RootFolder') { + rootFolder = folders[i]; + } + } + + const newFolder = { + data: { + type: 'folders', + attributes: { + name: folder.name, + 'folder-type': 'StandardFolder', + }, + relationships: { + parent: { + data: { + type: 'folders', + id: rootFolder.id, + }, + }, + }, + }, + }; + + return state.httpClient.post(`${context.type}/${context.id}/folders`, newFolder).then((response) => { + return response.data.data; + }); + }, + + async createFolder(store, { context, parent, folder }) { + const newFolder = { + data: { + type: 'folders', + attributes: { + name: folder.name, + 'folder-type': folder.type, + }, + relationships: { + parent: parent, + }, + }, + }; + + return state.httpClient.post(`${context.type}/${context.id}/folders`, newFolder).then((response) => { + return response.data.data; + }); + }, + + loadFolder({ dispatch }, folderId) { + const options = {}; + + return dispatch('folders/loadById', { id: folderId, options }, { root: true }); + }, + + storeCoursewareSettings({ dispatch }, { instance }) { + return dispatch('courseware-instances/update', instance, { root: true }); + }, + + async loadTeacherStatus({ dispatch, rootGetters, state, commit, getters }, userId) { + const user = rootGetters['users/byId']({ id: userId }); + + if (user.attributes.permission === 'root') { + commit('setUserIsTeacher', true); + return; + } + if (user.attributes.permission === 'admin') { + await dispatch('courses/loadById', { id: state.context.id }); + const course = rootGetters['courses/byId']({id: state.context.id }); + const instituteId = course.relationships.institute.data.id; + + const parent = { type: 'users', id: `${userId}` }; + const relationship = 'institute-memberships'; + const options = {}; + await dispatch('institute-memberships/loadRelated', { parent, relationship, options }, { root: true }); + const instituteMemberships = rootGetters['institute-memberships/all']; + const instituteMembership = instituteMemberships.filter(membership => membership.relationships.institute.data.id === instituteId); + + if (instituteMembership.length > 0 && instituteMembership[0].attributes.permission === 'admin') { + commit('setUserIsTeacher', true); + return; + } + } + + const membershipId = `${state.context.id}_${userId}`; + try { + await dispatch('course-memberships/loadById', { id: membershipId }); + } catch (error) { + console.error(`Could not find course membership for ${membershipId}.`); + commit('setUserIsTeacher', false); + + return false; + } + const membership = rootGetters['course-memberships/byId']({ id: membershipId }); + if (membership) { + const membershipPermission = membership.attributes.permission; + commit('setUserIsTeacher', membershipPermission === 'dozent' || membershipPermission === 'tutor'); + + return true; + } else { + console.error(`Could not find course membership for ${membershipId}.`); + commit('setUserIsTeacher', false); + + return false; + } + }, + + uploadImageForStructuralElement({ dispatch, state }, { structuralElement, file }) { + const formData = new FormData(); + formData.append('image', file); + + const url = `courseware-structural-elements/${structuralElement.id}/image`; + return state.httpClient.post(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, + + setImportFilesState({ commit }, state) { + commit('setImportFilesState', state); + }, + setImportFilesProgress({ commit }, percent) { + commit('setImportFilesProgress', percent); + }, + setImportStructuresState({ commit }, state) { + commit('setImportStructuresState', state); + }, + setImportStructuresProgress({ commit }, percent) { + commit('setImportStructuresProgress', percent); + }, + setImportErrors({ commit }, errors) { + commit('setImportErrors', errors); + }, + + async createStructuralElement({ dispatch }, { attributes, parentId }) { + const data = { + attributes, + relationships: { + parent: { + data: { + type: 'courseware-structural-elements', + id: parentId, + }, + }, + }, + }; + await dispatch('courseware-structural-elements/create', data, { root: true }); + }, + + + async updateStructuralElement({ dispatch }, { element, id }) { + await dispatch('courseware-structural-elements/update', element, { root: true }); + + return dispatch('loadStructuralElement', id); + }, + + async createContainer({ dispatch }, { attributes, structuralElementId }) { + const data = { + attributes, + relationships: { + 'structural-element': { + data: { + type: 'courseware-structural-elements', + id: structuralElementId, + }, + }, + }, + }; + await dispatch('courseware-containers/create', data, { root: true }); + + return dispatch('loadStructuralElement', structuralElementId); + }, + + async updateContainer({ dispatch }, { container, structuralElementId }) { + await dispatch('courseware-containers/update', container, { root: true }); + + return dispatch('loadStructuralElement', structuralElementId); + }, + + async createBlockInContainer({ dispatch }, { container, blockType }) { + const block = { + attributes: { + 'block-type': blockType, + payload: null, + }, + relationships: { + container: { + data: { type: container.type, id: container.id }, + }, + }, + }; + await dispatch('courseware-blocks/create', block, { root: true }); + + return dispatch('loadContainer', container.id); + }, + + async updateBlockInContainer({ dispatch }, { attributes, blockId, containerId }) { + const container = { + type: 'courseware-containers', + id: containerId, + }; + const block = { + type: 'courseware-blocks', + attributes: attributes, + id: blockId, + relationships: { + container: { + data: { type: container.type, id: container.id }, + }, + }, + }; + + await dispatch('courseware-blocks/update', block, { root: true }); + await dispatch('unlockObject', { id: blockId, type: 'courseware-blocks' }); + + return dispatch('loadContainer', containerId); + }, + + lockObject({ dispatch, getters }, { id, type }) { + return dispatch(`${type}/setRelated`, { + parent: { id, type }, + relationship: 'edit-blocker', + data: { + type: 'users', + id: getters.userId, + }, + }); + }, + + unlockObject({ dispatch }, { id, type }) { + return dispatch(`${type}/setRelated`, { + parent: { id, type }, + relationship: 'edit-blocker', + data: null, + }); + }, +}; + +export const mutations = { + setContext(state, data){ + state.context = data; + }, + setHttpClient(state, data){ + state.httpClient = data; + }, + setShowCompanionOverlay(state, data) { + state.showCompanionOverlay = data; + }, + setStyleCompanionOverlay(state, data) { + state.styleCompanionOverlay = data; + }, + setMsgCompanionOverlay(state, data) { + state.msgCompanionOverlay = data; + }, + setShowUnitAddDialog(state, data) { + state.showUnitAddDialog = data; + }, + setShowUnitCopyDialog(state, data) { + state.showUnitCopyDialog = data; + }, + setShowUnitImportDialog(state, data) { + state.showUnitImportDialog = data; + }, + setShowUnitLinkDialog(state, data) { + state.showUnitLinkDialog = data; + }, + setLicenses(state, data) { + state.licenses = data; + }, + setUserId(state, data) { + state.userId = data; + }, + setExportState(state, exportState) { + state.exportState = exportState; + }, + setExportProgress(state, exportProgress) { + state.exportProgress = exportProgress; + }, + setUserIsTeacher(state, isTeacher) { + state.teacherStatusLoaded = true; + state.userIsTeacher = isTeacher; + }, + setUrlHelper(state, urlHelper) { + state.urlHelper = urlHelper; + }, + + + setImportFilesState(state, importFilesState) { + state.importFilesState = importFilesState; + }, + + setImportFilesProgress(state, importFilesProgress) { + state.importFilesProgress = importFilesProgress; + }, + setImportErrors(state, importErrors) { + state.importErrors = importErrors; + }, + + setImportStructuresState(state, importStructuresState) { + state.importStructuresState = importStructuresState; + }, + + setImportStructuresProgress(state, importStructuresProgress) { + state.importStructuresProgress = importStructuresProgress; + }, +}; + +export default { + state, + actions, + mutations, + getters, +}; diff --git a/resources/vue/store/courseware/courseware-tasks.module.js b/resources/vue/store/courseware/courseware-tasks.module.js new file mode 100644 index 0000000000000000000000000000000000000000..fd5152dfa839f569e6ec2ed62f93372056ad25f5 --- /dev/null +++ b/resources/vue/store/courseware/courseware-tasks.module.js @@ -0,0 +1,37 @@ +const getDefaultState = () => { + return { + showTasksDistributeDialog: false, + }; +}; + +const initialState = getDefaultState(); + +const getters = { + showTasksDistributeDialog(state) { + return state.showTasksDistributeDialog; + }, +}; + +export const state = { ...initialState }; + +export const actions = { + // setters + setShowTasksDistributeDialog({ commit }, context) { + commit('setShowTasksDistributeDialog', context); + }, + + // other actions +}; + +export const mutations = { + setShowTasksDistributeDialog(state, data){ + state.showTasksDistributeDialog = data; + }, +}; + +export default { + state, + actions, + mutations, + getters, +}; diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index b1f12f80914b4bc86aae794b8226ed0801667a58..71e6d38408d906c499a059fbe931744f02f47cc0 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -30,12 +30,15 @@ const getDefaultState = () => { showStructuralElementEditDialog: false, showStructuralElementAddDialog: false, + showStructuralElementImportDialog: false, + showStructuralElementCopyDialog: false, + showStructuralElementLinkDialog: false, showStructuralElementExportDialog: false, showStructuralElementPdfExportDialog: false, showStructuralElementInfoDialog: false, showStructuralElementDeleteDialog: false, showStructuralElementOerDialog: false, - showStructuralElementLinkDialog: false, + showStructuralElementPublicLinkDialog: false, showStructuralElementRemoveLockDialog: false, showSuggestOerDialog: false, @@ -83,7 +86,8 @@ const getters = { return rootGetters['courseware-structural-elements/byId']({ id }); }, currentElementBlocked(state, getters, rootState, rootGetters) { - return getters.currentStructuralElement?.relationships?.['edit-blocker']?.data !== null; + const elemData = getters.currentStructuralElement?.relationships?.['edit-blocker']?.data; + return elemData !== null && elemData !== ''; }, currentElementBlockerId(state, getters) { return getters.currentElementBlocked ? getters.currentStructuralElement?.relationships?.['edit-blocker']?.data?.id : null; @@ -172,6 +176,15 @@ const getters = { showStructuralElementAddDialog(state) { return state.showStructuralElementAddDialog; }, + showStructuralElementCopyDialog(state) { + return state.showStructuralElementCopyDialog; + }, + showStructuralElementLinkDialog(state) { + return state.showStructuralElementLinkDialog; + }, + showStructuralElementImportDialog(state) { + return state.showStructuralElementImportDialog; + }, showStructuralElementExportDialog(state) { return state.showStructuralElementExportDialog; }, @@ -187,8 +200,8 @@ const getters = { showStructuralElementDeleteDialog(state) { return state.showStructuralElementDeleteDialog; }, - showStructuralElementLinkDialog(state) { - return state.showStructuralElementLinkDialog; + showStructuralElementPublicLinkDialog(state) { + return state.showStructuralElementPublicLinkDialog; }, showStructuralElementRemoveLockDialog(state) { return state.showStructuralElementRemoveLockDialog; @@ -301,16 +314,7 @@ export const actions = { const activities = rootGetters['users/all']; - const parentFetchers = activities - .filter(({ type }) => type === 'activities') - .map((activity) => this.dispatch( - 'courseware-structural-elements/loadById', - { - id: activity.relationships.object.meta['object-id'], - }, - )); - - return Promise.all(parentFetchers).then(() => activities); + return activities.filter(({ type }) => type === 'activities'); }, async createFile(context, { file, filedata, folder }) { @@ -434,8 +438,8 @@ export const actions = { // console.log(resp); }); }, - async copyStructuralElement({ dispatch, getters, rootGetters }, { parentId, elementId, removePurpose, migrate }) { - const copy = { data: { parent_id: parentId, remove_purpose: removePurpose, migrate: migrate } }; + async copyStructuralElement({ dispatch, getters, rootGetters }, { parentId, elementId, removePurpose, migrate, modifications }) { + const copy = { data: { parent_id: parentId, remove_purpose: removePurpose, migrate: migrate, modifications: modifications } }; const result = await state.httpClient.post(`courseware-structural-elements/${elementId}/copy`, copy); const id = result.data.data.id; @@ -836,6 +840,18 @@ export const actions = { context.commit('setShowStructuralElementAddDialog', bool); }, + showElementImportDialog(context, bool) { + context.commit('setShowStructuralElementImportDialog', bool); + }, + + showElementCopyDialog(context, bool) { + context.commit('setShowStructuralElementCopyDialog', bool); + }, + + showElementLinkDialog(context, bool) { + context.commit('setShowStructuralElementLinkDialog', bool); + }, + showElementExportDialog(context, bool) { context.commit('setShowStructuralElementExportDialog', bool); }, @@ -860,8 +876,8 @@ export const actions = { context.commit('setShowStructuralElementDeleteDialog', bool); }, - showElementLinkDialog(context, bool) { - context.commit('setShowStructuralElementLinkDialog', bool); + showElementPublicLinkDialog(context, bool) { + context.commit('setShowStructuralElementPublicLinkDialog', bool); }, showElementRemoveLockDialog(context, bool) { @@ -1294,7 +1310,32 @@ export const actions = { await dispatch('courseware-public-links/update', link, { root: true }); return dispatch('courseware-public-links/loadById', { id: link.id }, { root: true }); - } + }, + + loadCourseUnits({ dispatch }, cid) { + const parent = { type: 'courses', id: cid }; + const relationship = 'courseware-units'; + const options = { include: 'structural-element' } + + return dispatch('loadRelatedPaginated', { + type: 'courseware-units', + parent, + relationship, + options, + }); + }, + loadUserUnits({ dispatch }, uid) { + const parent = { type: 'users', id: uid }; + const relationship = 'courseware-units'; + const options = { include: 'structural-element' } + + return dispatch('loadRelatedPaginated', { + type: 'courseware-units', + parent, + relationship, + options, + }); + }, }; /* eslint no-param-reassign: ["error", { "props": false }] */ @@ -1393,6 +1434,18 @@ export const mutations = { state.showStructuralElementAddDialog = showAdd; }, + setShowStructuralElementImportDialog(state, showImport) { + state.showStructuralElementImportDialog = showImport; + }, + + setShowStructuralElementCopyDialog(state, showCopy) { + state.showStructuralElementCopyDialog = showCopy; + }, + + setShowStructuralElementLinkDialog(state, showLink) { + state.showStructuralElementLinkDialog = showLink; + }, + setShowStructuralElementExportDialog(state, showExport) { state.showStructuralElementExportDialog = showExport; }, @@ -1421,8 +1474,8 @@ export const mutations = { state.showOverviewElementAddDialog = showAdd; }, - setShowStructuralElementLinkDialog(state, showLink) { - state.showStructuralElementLinkDialog = showLink; + setShowStructuralElementPublicLinkDialog(state, showPublicLink) { + state.showStructuralElementPublicLinkDialog = showPublicLink; }, setShowStructuralElementRemoveLockDialog(state, showRemoveLock) { diff --git a/resources/vue/store/courseware/structure.module.js b/resources/vue/store/courseware/structure.module.js index 8f2a30f0b16e01e58370b2053cbb6fb792597827..0eba83936a10253ead770e1208a372cc6f144739 100644 --- a/resources/vue/store/courseware/structure.module.js +++ b/resources/vue/store/courseware/structure.module.js @@ -125,7 +125,11 @@ const actions = { }, loadInstance({ commit, dispatch, rootGetters }, context) { - const parent = context; + let parent = context; + parent = { + type: context.type, + id: context.id + '_' + context.unit + } const relationship = 'courseware'; const options = { include: 'bookmarks,root',