From 333b1181662f26afe13256b26c6c87605d627d10 Mon Sep 17 00:00:00 2001 From: Ron Lucke <lucke@elan-ev.de> Date: Mon, 7 Feb 2022 14:27:25 +0000 Subject: [PATCH] StEP 00357 --- app/controllers/admin/courseware.php | 34 + app/controllers/contents/courseware.php | 86 ++- app/controllers/course/courseware.php | 24 +- app/routes/Activity.php | 11 + .../admin/courseware/admin_action_widget.php | 1 + .../admin/courseware/admin_view_widget.php | 1 + app/views/admin/courseware/index.php | 1 + .../courseware/bookmark_filter_widget.php | 1 + app/views/contents/courseware/bookmarks.php | 38 +- .../contents/courseware/courses_overview.php | 2 +- app/views/contents/courseware/index.php | 51 +- .../courseware/overview_action_widget.php | 1 + .../courseware/overview_filter_widget.php | 1 + app/views/course/courseware/dashboard.php | 11 +- .../courseware/dashboard_view_widget.php | 1 + .../5.1.17_add_courseware_templates.php | 34 + db/migrations/5.1.18_add_courseware_tasks.php | 78 +++ ...urseware_structural_element_discussion.php | 49 ++ lib/activities/Activity.php | 1 + lib/activities/ActivityObserver.php | 34 + lib/activities/Context.php | 42 +- lib/activities/CourseContext.php | 3 + lib/activities/CoursewareProvider.php | 244 ++++++- lib/activities/Filter.php | 78 ++- lib/classes/JsonApi/RouteMap.php | 40 ++ .../JsonApi/Routes/Courseware/Authority.php | 178 ++++- .../Routes/Courseware/BlockCommentsCreate.php | 3 + .../Courseware/BlockFeedbacksCreate.php | 3 + .../Courseware/BlockFeedbacksDelete.php | 33 + .../Courseware/BlockFeedbacksUpdate.php | 113 +++ .../Routes/Courseware/BlocksCreate.php | 2 + .../Routes/Courseware/BlocksUpdate.php | 3 + .../BookmarkedStructuralElementsIndex.php | 2 +- .../Routes/Courseware/ContainersUpdate.php | 2 + .../Courseware/CoursewareInstancesHelper.php | 5 +- .../Rel/BookmarkedStructuralElements.php | 4 + .../Rel/UsersBookmarkedStructuralElements.php | 180 +++++ .../StructuralElementCommentsCreate.php | 86 +++ .../StructuralElementCommentsDelete.php | 33 + ...ementCommentsOfStructuralElementsIndex.php | 35 + .../StructuralElementCommentsShow.php | 34 + .../StructuralElementCommentsUpdate.php | 113 +++ .../StructuralElementFeedbackCreate.php | 86 +++ .../StructuralElementFeedbackDelete.php | 33 + ...ementFeedbackOfStructuralElementsIndex.php | 38 + .../StructuralElementFeedbackShow.php | 36 + .../StructuralElementFeedbackUpdate.php | 113 +++ .../Courseware/StructuralElementsCopy.php | 3 + .../Courseware/StructuralElementsCreate.php | 41 +- .../Routes/Courseware/TaskFeedbackCreate.php | 89 +++ .../Routes/Courseware/TaskFeedbackDelete.php | 36 + .../Routes/Courseware/TaskFeedbackShow.php | 38 + .../Routes/Courseware/TaskFeedbackUpdate.php | 84 +++ .../Routes/Courseware/TaskGroupsCreate.php | 212 ++++++ .../Routes/Courseware/TaskGroupsShow.php | 46 ++ .../JsonApi/Routes/Courseware/TasksDelete.php | 39 ++ .../JsonApi/Routes/Courseware/TasksIndex.php | 106 +++ .../JsonApi/Routes/Courseware/TasksShow.php | 46 ++ .../JsonApi/Routes/Courseware/TasksUpdate.php | 118 ++++ .../Routes/Courseware/TemplatesCreate.php | 88 +++ .../Routes/Courseware/TemplatesDelete.php | 33 + .../Routes/Courseware/TemplatesIndex.php | 32 + .../Routes/Courseware/TemplatesShow.php | 34 + .../Routes/Courseware/TemplatesUpdate.php | 80 +++ ...UsersBookmarkedStructuralElementsIndex.php | 55 ++ lib/classes/JsonApi/SchemaMap.php | 6 + .../Schemas/Courseware/StructuralElement.php | 64 +- .../Courseware/StructuralElementComment.php | 62 ++ .../Courseware/StructuralElementFeedback.php | 62 ++ .../JsonApi/Schemas/Courseware/Task.php | 85 +++ .../Schemas/Courseware/TaskFeedback.php | 61 ++ .../JsonApi/Schemas/Courseware/TaskGroup.php | 104 +++ .../JsonApi/Schemas/Courseware/Template.php | 44 ++ lib/classes/JsonApi/Schemas/User.php | 18 + lib/models/Courseware/Block.php | 15 + lib/models/Courseware/BlockComment.php | 16 + lib/models/Courseware/BlockFeedback.php | 16 + .../Courseware/BlockTypes/BlockType.php | 15 +- lib/models/Courseware/BlockTypes/Code.php | 8 + lib/models/Courseware/BlockTypes/Confirm.php | 8 + lib/models/Courseware/BlockTypes/Date.php | 8 + lib/models/Courseware/BlockTypes/Headline.php | 9 + lib/models/Courseware/BlockTypes/KeyPoint.php | 8 + lib/models/Courseware/BlockTypes/Link.php | 9 + lib/models/Courseware/BlockTypes/Text.php | 8 + .../Courseware/BlockTypes/Typewriter.php | 8 + lib/models/Courseware/Bookmark.php | 6 +- .../ContainerTypes/AccordionContainer.php | 24 + .../ContainerTypes/ContainerType.php | 11 + .../ContainerTypes/ListContainer.php | 20 + .../ContainerTypes/TabsContainer.php | 24 + lib/models/Courseware/StructuralElement.php | 160 ++++- .../Courseware/StructuralElementComment.php | 42 ++ .../Courseware/StructuralElementFeedback.php | 42 ++ lib/models/Courseware/Task.php | 217 ++++++ lib/models/Courseware/TaskFeedback.php | 58 ++ lib/models/Courseware/TaskGroup.php | 61 ++ lib/models/Courseware/Template.php | 28 + lib/navigation/AdminNavigation.php | 7 + lib/navigation/ContentsNavigation.php | 2 +- package.json | 1 + .../images/icons/black/bullet-arrow.svg | 1 + .../assets/images/icons/black/bullet-dot.svg | 1 + .../icons/black/bullet-double-arrow.svg | 1 + .../assets/images/icons/black/bullet-line.svg | 1 + .../images/icons/black/category-draft.svg | 1 + .../images/icons/black/category-others.svg | 1 + .../images/icons/black/category-portfolio.svg | 1 + .../images/icons/black/category-task.svg | 1 + .../images/icons/black/category-template.svg | 1 + public/assets/images/icons/black/content2.svg | 1 + .../assets/images/icons/blue/bullet-arrow.svg | 1 + .../assets/images/icons/blue/bullet-dot.svg | 1 + .../images/icons/blue/bullet-double-arrow.svg | 1 + .../assets/images/icons/blue/bullet-line.svg | 1 + .../images/icons/blue/category-draft.svg | 1 + .../images/icons/blue/category-others.svg | 1 + .../images/icons/blue/category-portfolio.svg | 1 + .../images/icons/blue/category-task.svg | 1 + .../images/icons/blue/category-template.svg | 1 + public/assets/images/icons/blue/content2.svg | 2 +- .../images/icons/green/bullet-arrow.svg | 1 + .../assets/images/icons/green/bullet-dot.svg | 1 + .../icons/green/bullet-double-arrow.svg | 1 + .../assets/images/icons/green/bullet-line.svg | 1 + .../images/icons/green/category-draft.svg | 1 + .../images/icons/green/category-others.svg | 1 + .../images/icons/green/category-portfolio.svg | 1 + .../images/icons/green/category-task.svg | 1 + .../images/icons/green/category-template.svg | 1 + public/assets/images/icons/green/content2.svg | 1 + .../assets/images/icons/grey/bullet-arrow.svg | 1 + .../assets/images/icons/grey/bullet-dot.svg | 1 + .../images/icons/grey/bullet-double-arrow.svg | 1 + .../assets/images/icons/grey/bullet-line.svg | 1 + .../images/icons/grey/category-draft.svg | 1 + .../images/icons/grey/category-others.svg | 1 + .../images/icons/grey/category-portfolio.svg | 1 + .../images/icons/grey/category-task.svg | 1 + .../images/icons/grey/category-template.svg | 1 + public/assets/images/icons/grey/content2.svg | 1 + .../assets/images/icons/red/bullet-arrow.svg | 1 + public/assets/images/icons/red/bullet-dot.svg | 1 + .../images/icons/red/bullet-double-arrow.svg | 1 + .../assets/images/icons/red/bullet-line.svg | 1 + .../images/icons/red/category-draft.svg | 1 + .../images/icons/red/category-others.svg | 1 + .../images/icons/red/category-portfolio.svg | 1 + .../assets/images/icons/red/category-task.svg | 1 + .../images/icons/red/category-template.svg | 1 + public/assets/images/icons/red/content2.svg | 1 + .../images/icons/white/bullet-arrow.svg | 1 + .../assets/images/icons/white/bullet-dot.svg | 1 + .../icons/white/bullet-double-arrow.svg | 1 + .../assets/images/icons/white/bullet-line.svg | 1 + .../images/icons/white/category-draft.svg | 1 + .../images/icons/white/category-others.svg | 1 + .../images/icons/white/category-portfolio.svg | 1 + .../images/icons/white/category-task.svg | 1 + .../images/icons/white/category-template.svg | 1 + public/assets/images/icons/white/content2.svg | 1 + .../images/icons/yellow/bullet-arrow.svg | 1 + .../assets/images/icons/yellow/bullet-dot.svg | 1 + .../icons/yellow/bullet-double-arrow.svg | 1 + .../images/icons/yellow/bullet-line.svg | 1 + .../images/icons/yellow/category-draft.svg | 1 + .../images/icons/yellow/category-others.svg | 1 + .../icons/yellow/category-portfolio.svg | 1 + .../images/icons/yellow/category-task.svg | 1 + .../images/icons/yellow/category-template.svg | 1 + .../assets/images/icons/yellow/content2.svg | 1 + .../core/ActivityFeed/ActivityFeed.php | 3 +- .../javascripts/bootstrap/courseware.js | 33 + .../assets/stylesheets/scss/courseware.scss | 658 +++++++++++++++--- .../vue/components/courseware/AdminApp.vue | 33 + .../courseware/ContentBookmarkApp.vue | 21 + .../courseware/ContentOverviewApp.vue | 25 + .../CoursewareAccordionContainer.vue | 51 +- .../courseware/CoursewareActionWidget.vue | 74 +- .../courseware/CoursewareActivityItem.vue | 40 +- .../CoursewareAdminActionWidget.vue | 30 + .../courseware/CoursewareAdminTemplates.vue | 237 +++++++ .../courseware/CoursewareAdminViewWidget.vue | 31 + .../courseware/CoursewareBlockActions.vue | 21 +- .../courseware/CoursewareBlockComments.vue | 72 +- .../courseware/CoursewareBlockDiscussion.vue | 60 ++ .../courseware/CoursewareBlockFeedback.vue | 72 +- .../courseware/CoursewareChartBlock.vue | 2 +- .../courseware/CoursewareCollapsibleBox.vue | 5 + .../courseware/CoursewareContainerActions.vue | 12 +- .../CoursewareContentBookmarkFilterWidget.vue | 42 ++ .../courseware/CoursewareContentBookmarks.vue | 107 +++ .../CoursewareContentOverviewActionWidget.vue | 23 + .../CoursewareContentOverviewElements.vue | 535 ++++++++++++++ .../CoursewareContentOverviewFilterWidget.vue | 51 ++ .../courseware/CoursewareCourseDashboard.vue | 86 ++- .../courseware/CoursewareCourseManager.vue | 265 +++---- .../CoursewareDashboardActivities.vue | 99 ++- .../CoursewareDashboardProgress.vue | 9 +- .../CoursewareDashboardStudents.vue | 377 ++++++++++ .../courseware/CoursewareDashboardTasks.vue | 264 +++++++ .../CoursewareDashboardViewWidget.vue | 59 ++ .../courseware/CoursewareDateInput.vue | 34 + .../courseware/CoursewareDefaultBlock.vue | 66 +- .../CoursewareDefaultBlockElements.vue | 4 - .../courseware/CoursewareDefaultContainer.vue | 21 + .../courseware/CoursewareHeadlineBlock.vue | 8 +- .../courseware/CoursewareImageMapBlock.vue | 2 +- .../courseware/CoursewareKeyPointBlock.vue | 4 +- .../courseware/CoursewareListContainer.vue | 80 ++- .../CoursewareManagerTaskDistributor.vue | 316 +++++++++ .../courseware/CoursewareOblong.vue | 12 +- .../CoursewareStructuralElement.vue | 245 ++++++- .../CoursewareStructuralElementComments.vue | 128 ++++ .../CoursewareStructuralElementDiscussion.vue | 60 ++ .../CoursewareStructuralElementFeedback.vue | 130 ++++ .../CoursewareTableOfContentsBlock.vue | 23 +- .../courseware/CoursewareTabsContainer.vue | 62 +- .../courseware/CoursewareTreeItem.vue | 122 +++- .../courseware/CoursewareViewWidget.vue | 45 +- .../components/courseware/DashboardApp.vue | 29 +- .../vue/components/courseware/IndexApp.vue | 4 +- resources/vue/courseware-admin-app.js | 42 ++ .../vue/courseware-content-bookmark-app.js | 81 +++ .../vue/courseware-content-overview-app.js | 97 +++ resources/vue/courseware-dashboard-app.js | 81 +++ resources/vue/courseware-index-app.js | 5 + resources/vue/courseware-manager-app.js | 2 + .../vue/mixins/courseware/task-helper.js | 66 ++ .../courseware/courseware-admin.module.js | 47 ++ .../vue/store/courseware/courseware.module.js | 259 ++++++- 231 files changed, 9410 insertions(+), 652 deletions(-) create mode 100755 app/controllers/admin/courseware.php create mode 100755 app/views/admin/courseware/admin_action_widget.php create mode 100755 app/views/admin/courseware/admin_view_widget.php create mode 100755 app/views/admin/courseware/index.php create mode 100755 app/views/contents/courseware/bookmark_filter_widget.php create mode 100755 app/views/contents/courseware/overview_action_widget.php create mode 100755 app/views/contents/courseware/overview_filter_widget.php create mode 100755 app/views/course/courseware/dashboard_view_widget.php create mode 100755 db/migrations/5.1.17_add_courseware_templates.php create mode 100755 db/migrations/5.1.18_add_courseware_tasks.php create mode 100755 db/migrations/5.1.19_add_courseware_structural_element_discussion.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksDelete.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksUpdate.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/Rel/UsersBookmarkedStructuralElements.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsCreate.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsDelete.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsOfStructuralElementsIndex.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsShow.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsUpdate.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackCreate.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackDelete.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackOfStructuralElementsIndex.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackShow.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackUpdate.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TaskFeedbackCreate.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TaskFeedbackDelete.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TaskFeedbackShow.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TaskFeedbackUpdate.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TasksDelete.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TasksIndex.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TasksShow.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TemplatesCreate.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TemplatesDelete.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TemplatesIndex.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TemplatesShow.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/TemplatesUpdate.php create mode 100755 lib/classes/JsonApi/Routes/Courseware/UsersBookmarkedStructuralElementsIndex.php create mode 100755 lib/classes/JsonApi/Schemas/Courseware/StructuralElementComment.php create mode 100755 lib/classes/JsonApi/Schemas/Courseware/StructuralElementFeedback.php create mode 100755 lib/classes/JsonApi/Schemas/Courseware/Task.php create mode 100755 lib/classes/JsonApi/Schemas/Courseware/TaskFeedback.php create mode 100755 lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php create mode 100755 lib/classes/JsonApi/Schemas/Courseware/Template.php create mode 100755 lib/models/Courseware/StructuralElementComment.php create mode 100755 lib/models/Courseware/StructuralElementFeedback.php create mode 100755 lib/models/Courseware/Task.php create mode 100755 lib/models/Courseware/TaskFeedback.php create mode 100644 lib/models/Courseware/TaskGroup.php create mode 100755 lib/models/Courseware/Template.php create mode 100644 public/assets/images/icons/black/bullet-arrow.svg create mode 100644 public/assets/images/icons/black/bullet-dot.svg create mode 100644 public/assets/images/icons/black/bullet-double-arrow.svg create mode 100644 public/assets/images/icons/black/bullet-line.svg create mode 100644 public/assets/images/icons/black/category-draft.svg create mode 100644 public/assets/images/icons/black/category-others.svg create mode 100644 public/assets/images/icons/black/category-portfolio.svg create mode 100644 public/assets/images/icons/black/category-task.svg create mode 100644 public/assets/images/icons/black/category-template.svg create mode 100644 public/assets/images/icons/black/content2.svg create mode 100644 public/assets/images/icons/blue/bullet-arrow.svg create mode 100644 public/assets/images/icons/blue/bullet-dot.svg create mode 100644 public/assets/images/icons/blue/bullet-double-arrow.svg create mode 100644 public/assets/images/icons/blue/bullet-line.svg create mode 100644 public/assets/images/icons/blue/category-draft.svg create mode 100644 public/assets/images/icons/blue/category-others.svg create mode 100644 public/assets/images/icons/blue/category-portfolio.svg create mode 100644 public/assets/images/icons/blue/category-task.svg create mode 100644 public/assets/images/icons/blue/category-template.svg create mode 100644 public/assets/images/icons/green/bullet-arrow.svg create mode 100644 public/assets/images/icons/green/bullet-dot.svg create mode 100644 public/assets/images/icons/green/bullet-double-arrow.svg create mode 100644 public/assets/images/icons/green/bullet-line.svg create mode 100644 public/assets/images/icons/green/category-draft.svg create mode 100644 public/assets/images/icons/green/category-others.svg create mode 100644 public/assets/images/icons/green/category-portfolio.svg create mode 100644 public/assets/images/icons/green/category-task.svg create mode 100644 public/assets/images/icons/green/category-template.svg create mode 100644 public/assets/images/icons/green/content2.svg create mode 100644 public/assets/images/icons/grey/bullet-arrow.svg create mode 100644 public/assets/images/icons/grey/bullet-dot.svg create mode 100644 public/assets/images/icons/grey/bullet-double-arrow.svg create mode 100644 public/assets/images/icons/grey/bullet-line.svg create mode 100644 public/assets/images/icons/grey/category-draft.svg create mode 100644 public/assets/images/icons/grey/category-others.svg create mode 100644 public/assets/images/icons/grey/category-portfolio.svg create mode 100644 public/assets/images/icons/grey/category-task.svg create mode 100644 public/assets/images/icons/grey/category-template.svg create mode 100644 public/assets/images/icons/grey/content2.svg create mode 100644 public/assets/images/icons/red/bullet-arrow.svg create mode 100644 public/assets/images/icons/red/bullet-dot.svg create mode 100644 public/assets/images/icons/red/bullet-double-arrow.svg create mode 100644 public/assets/images/icons/red/bullet-line.svg create mode 100644 public/assets/images/icons/red/category-draft.svg create mode 100644 public/assets/images/icons/red/category-others.svg create mode 100644 public/assets/images/icons/red/category-portfolio.svg create mode 100644 public/assets/images/icons/red/category-task.svg create mode 100644 public/assets/images/icons/red/category-template.svg create mode 100644 public/assets/images/icons/red/content2.svg create mode 100644 public/assets/images/icons/white/bullet-arrow.svg create mode 100644 public/assets/images/icons/white/bullet-dot.svg create mode 100644 public/assets/images/icons/white/bullet-double-arrow.svg create mode 100644 public/assets/images/icons/white/bullet-line.svg create mode 100644 public/assets/images/icons/white/category-draft.svg create mode 100644 public/assets/images/icons/white/category-others.svg create mode 100644 public/assets/images/icons/white/category-portfolio.svg create mode 100644 public/assets/images/icons/white/category-task.svg create mode 100644 public/assets/images/icons/white/category-template.svg create mode 100644 public/assets/images/icons/white/content2.svg create mode 100644 public/assets/images/icons/yellow/bullet-arrow.svg create mode 100644 public/assets/images/icons/yellow/bullet-dot.svg create mode 100644 public/assets/images/icons/yellow/bullet-double-arrow.svg create mode 100644 public/assets/images/icons/yellow/bullet-line.svg create mode 100644 public/assets/images/icons/yellow/category-draft.svg create mode 100644 public/assets/images/icons/yellow/category-others.svg create mode 100644 public/assets/images/icons/yellow/category-portfolio.svg create mode 100644 public/assets/images/icons/yellow/category-task.svg create mode 100644 public/assets/images/icons/yellow/category-template.svg create mode 100644 public/assets/images/icons/yellow/content2.svg create mode 100755 resources/vue/components/courseware/AdminApp.vue create mode 100755 resources/vue/components/courseware/ContentBookmarkApp.vue create mode 100755 resources/vue/components/courseware/ContentOverviewApp.vue create mode 100755 resources/vue/components/courseware/CoursewareAdminActionWidget.vue create mode 100755 resources/vue/components/courseware/CoursewareAdminTemplates.vue create mode 100755 resources/vue/components/courseware/CoursewareAdminViewWidget.vue create mode 100755 resources/vue/components/courseware/CoursewareBlockDiscussion.vue create mode 100755 resources/vue/components/courseware/CoursewareContentBookmarkFilterWidget.vue create mode 100755 resources/vue/components/courseware/CoursewareContentBookmarks.vue create mode 100755 resources/vue/components/courseware/CoursewareContentOverviewActionWidget.vue create mode 100755 resources/vue/components/courseware/CoursewareContentOverviewElements.vue create mode 100755 resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue create mode 100755 resources/vue/components/courseware/CoursewareDashboardStudents.vue create mode 100755 resources/vue/components/courseware/CoursewareDashboardTasks.vue create mode 100755 resources/vue/components/courseware/CoursewareDashboardViewWidget.vue create mode 100644 resources/vue/components/courseware/CoursewareDateInput.vue create mode 100755 resources/vue/components/courseware/CoursewareManagerTaskDistributor.vue create mode 100755 resources/vue/components/courseware/CoursewareStructuralElementComments.vue create mode 100755 resources/vue/components/courseware/CoursewareStructuralElementDiscussion.vue create mode 100755 resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue create mode 100755 resources/vue/courseware-admin-app.js create mode 100755 resources/vue/courseware-content-bookmark-app.js create mode 100755 resources/vue/courseware-content-overview-app.js create mode 100755 resources/vue/mixins/courseware/task-helper.js create mode 100755 resources/vue/store/courseware/courseware-admin.module.js diff --git a/app/controllers/admin/courseware.php b/app/controllers/admin/courseware.php new file mode 100755 index 00000000000..964ff4de13a --- /dev/null +++ b/app/controllers/admin/courseware.php @@ -0,0 +1,34 @@ +<?php + +class Admin_CoursewareController extends AuthenticatedController +{ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + $GLOBALS['perm']->check('root'); + PageLayout::setTitle(_('Coursewareverwaltung')); + Navigation::activateItem('/admin/locations/courseware'); + } + + public function index_action() + { + $this->setSidebar(); + } + + private function setSidebar() + { + $sidebar = Sidebar::Get(); + $views = new TemplateWidget( + _('Ansichten'), + $this->get_template_factory()->open('admin/courseware/admin_view_widget') + ); + $sidebar->addWidget($views)->addLayoutCSSClass('courseware-admin-view-widget'); + + $views = new TemplateWidget( + _('Aktionen'), + $this->get_template_factory()->open('admin/courseware/admin_action_widget') + ); + $sidebar->addWidget($views)->addLayoutCSSClass('courseware-admin-action-widget'); + } + +} \ No newline at end of file diff --git a/app/controllers/contents/courseware.php b/app/controllers/contents/courseware.php index c00f3e08192..ffec9de5a98 100755 --- a/app/controllers/contents/courseware.php +++ b/app/controllers/contents/courseware.php @@ -30,18 +30,34 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function index_action($action = false, $widgetId = null) + public function index_action() { - Navigation::activateItem('/contents/courseware/projects'); - $this->setProjectsSidebar($action); - $this->courseware_root = StructuralElement::getCoursewareUser($this->user->id); + Navigation::activateItem('/contents/courseware/overview'); + $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 = StructuralElement::createEmptyCourseware($this->user->id, 'user'); + $new = \Courseware\StructuralElement::createEmptyCourseware($this->user_id, 'user'); $this->courseware_root = $new->getRoot(); } + $this->licenses = $this->getLicences(); + } + + private function setOverviewSidebar() + { + $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'); - $this->elements = $this->getProjects('all'); + $views = new TemplateWidget( + _('Filter'), + $this->get_template_factory()->open('contents/courseware/overview_filter_widget') + ); + $sidebar->addWidget($views)->addLayoutCSSClass('courseware-overview-filter-widget'); } /** @@ -87,12 +103,7 @@ class Contents_CoursewareController extends AuthenticatedController $last[$this->user_id] = $this->entry_element_id; UserConfig::get($this->user_id)->store('COURSEWARE_LAST_ELEMENT', $last); - $this->licenses = array(); - $sorm_licenses = License::findBySQL("1 ORDER BY name ASC"); - foreach($sorm_licenses as $license) { - array_push($this->licenses, $license->toArray()); - } - $this->licenses = json_encode($this->licenses); + $this->licenses = $this->getLicences(); $this->oer_enabled = Config::get()->OERCAMPUS_ENABLED && $perm->have_perm(Config::get()->OER_PUBLIC_STATUS); } @@ -111,8 +122,16 @@ class Contents_CoursewareController extends AuthenticatedController $this->get_template_factory()->open('course/courseware/view_widget') ); $sidebar->addWidget($views)->addLayoutCSSClass('courseware-view-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()); + } + return json_encode($licenses); } /** @@ -141,31 +160,21 @@ class Contents_CoursewareController extends AuthenticatedController * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function bookmarks_action($action = false, $widgetId = null) + public function bookmarks_action() { Navigation::activateItem('/contents/courseware/bookmarks'); - $this->bookmarks = array(); - $cw_bookmarks = Courseware\Bookmark::findUsersBookmarks($this->user->id); - foreach($cw_bookmarks as $bookmark) { - $bm = array(); - $bm['bookmark'] = $bookmark; - $element = Courseware\StructuralElement::find($bookmark->element_id); - if(empty($element)) { - continue; - } - $element['payload'] = json_decode($element['payload'], true); - $bm['element'] = $element; - if ($element->range_type === 'course') { - $bm['url'] = URLHelper::getURL('dispatch.php/course/courseware/?cid='.$element['range_id'].'#/structural_element/'.$element['id']); - $bm['course'] = Course::find($element['range_id']); - } - if ($element->range_type === 'user' && $element->range_id === $this->user->id) { - $bm['url'] = URLHelper::getURL('dispatch.php/contents/courseware/courseware#/structural_element/'.$element['id']); - $bm['user'] = $this->user; - } + $this->user_id = $GLOBALS['user']->id; + $this->setBookmarkSidebar(); + } - array_push($this->bookmarks, $bm); - } + private function setBookmarkSidebar() + { + $sidebar = Sidebar::Get(); + $views = new TemplateWidget( + _('Filter'), + $this->get_template_factory()->open('contents/courseware/bookmark_filter_widget') + ); + $sidebar->addWidget($views)->addLayoutCSSClass('courseware-bookmark-filter-widget'); } /** @@ -419,4 +428,11 @@ class Contents_CoursewareController extends AuthenticatedController $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) + { + $element = \Courseware\StructuralElement::findOneById($element_id); + + $this->render_pdf($element->pdfExport($this->user), trim($element->title).'.pdf'); + } } diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index 1d93a0a7516..7c8cd786794 100755 --- a/app/controllers/course/courseware.php +++ b/app/controllers/course/courseware.php @@ -7,6 +7,7 @@ use Courseware\UserProgress; /** * @property ?string $entry_element_id * @property int $last_visitdate + * @property mixed $course_id * @property mixed $courseware_progress_data * @property mixed $courseware_chapter_counter */ @@ -71,10 +72,11 @@ class Course_CoursewareController extends AuthenticatedController public function dashboard_action(): void { global $perm, $user; - $course_progress = $perm->have_studip_perm('dozent', Context::getId(), $user->id); - $this->courseware_progress_data = $this->getProgressData($course_progress); + $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(); } public function manager_action(): void @@ -89,6 +91,13 @@ class Course_CoursewareController extends AuthenticatedController } } + public function pdf_export_action($element_id) + { + $element = \Courseware\StructuralElement::findOneById($element_id); + + $this->render_pdf($element->pdfExport($this->user), trim($element->title).'.pdf'); + } + private function setIndexSidebar(): void { $sidebar = Sidebar::Get(); @@ -105,6 +114,17 @@ class Course_CoursewareController extends AuthenticatedController $sidebar->addWidget($views)->addLayoutCSSClass('courseware-view-widget'); } + + private function setDashboardSidebar(): 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 */ diff --git a/app/routes/Activity.php b/app/routes/Activity.php index 8f6127fbeea..a37c01ec7f7 100644 --- a/app/routes/Activity.php +++ b/app/routes/Activity.php @@ -70,6 +70,17 @@ class Activity extends \RESTAPI\RouteMap $scrollfrom = \Request::int('scrollfrom', false); $filtertype = \Request::get('filtertype', ''); + $objectType = \Request::get('object_type', ''); + $filter->setObjectType($objectType); + + $objectId = \Request::get('object_id', ''); + $filter->setObjectId($objectId); + + $context = \Request::get('context_type', ''); + $filter->setContext($context); + + $contextId = \Request::get('context_id', ''); + $filter->setContextId($contextId); if (!empty($filtertype)) { $filter->setType(json_decode($filtertype)); diff --git a/app/views/admin/courseware/admin_action_widget.php b/app/views/admin/courseware/admin_action_widget.php new file mode 100755 index 00000000000..85f366ce3a6 --- /dev/null +++ b/app/views/admin/courseware/admin_action_widget.php @@ -0,0 +1 @@ +<aside id="courseware-admin-action-widget" class="widget-sidebar"></aside> \ No newline at end of file diff --git a/app/views/admin/courseware/admin_view_widget.php b/app/views/admin/courseware/admin_view_widget.php new file mode 100755 index 00000000000..a46667c854d --- /dev/null +++ b/app/views/admin/courseware/admin_view_widget.php @@ -0,0 +1 @@ +<aside id="courseware-admin-view-widget" class="widget-sidebar"></aside> \ No newline at end of file diff --git a/app/views/admin/courseware/index.php b/app/views/admin/courseware/index.php new file mode 100755 index 00000000000..b40dc545db6 --- /dev/null +++ b/app/views/admin/courseware/index.php @@ -0,0 +1 @@ +<div id="courseware-admin-app"></div> \ No newline at end of file diff --git a/app/views/contents/courseware/bookmark_filter_widget.php b/app/views/contents/courseware/bookmark_filter_widget.php new file mode 100755 index 00000000000..8bfb00e1601 --- /dev/null +++ b/app/views/contents/courseware/bookmark_filter_widget.php @@ -0,0 +1 @@ +<aside id="courseware-content-bookmark-filter-widget" class="widget-sidebar"></aside> \ No newline at end of file diff --git a/app/views/contents/courseware/bookmarks.php b/app/views/contents/courseware/bookmarks.php index 14975e57f3f..a0803209707 100755 --- a/app/views/contents/courseware/bookmarks.php +++ b/app/views/contents/courseware/bookmarks.php @@ -1,34 +1,6 @@ -<div class="cw-bookmarks"> - <? if(!empty($bookmarks)): ?> - <ul class="cw-tiles"> - <? foreach($bookmarks as $bookmark) :?> - <li class="tile <?= htmlReady($bookmark['element']['payload']['color'])?>"> - <a href="<?= htmlReady($bookmark['url'])?>"> - <? if ($element->getImageUrl() === null) : ?> - <div class="preview-image default-image"></div> - <? else : ?> - <div class="preview-image" style="background-image: url(<?= htmlReady($element->getImageUrl()) ?>)" ></div> - <? endif; ?> - - <div class="description"> - <header><?= htmlReady($bookmark['element']['title']) ?></header> - <div class="description-text-wrapper"> - <p><?= htmlReady($bookmark['element']['payload']['description']) ?></p> - </div> - <footer> - <? if($bookmark['course']): ?> - <?= Icon::create('seminar', Icon::ROLE_INFO_ALT)?> <?= htmlReady($bookmark['course']['name'])?> - <? endif; ?> - <? if($bookmark['user']): ?> - <?= Icon::create('headache', Icon::ROLE_INFO_ALT)?> <?= htmlReady($bookmark['user']->getFullName())?> - <? endif; ?> - </footer> - </div> - </a> - </li> - <? endforeach; ?> - </ul> - <? else: ?> - <?= MessageBox::info(_('Sie haben noch keine Lesezeichen angelegt.')); ?> - <? endif; ?> +<div + id="courseware-content-bookmark-app" + entry-type="users" + entry-id="<?= $user_id ?>" +> </div> diff --git a/app/views/contents/courseware/courses_overview.php b/app/views/contents/courseware/courses_overview.php index 4786af7a110..f6a3d98816f 100644 --- a/app/views/contents/courseware/courses_overview.php +++ b/app/views/contents/courseware/courses_overview.php @@ -1,4 +1,4 @@ -<div class="cw-content-projects"> +<div class="cw-content-courses"> <? if (empty($sem_courses)) : ?> <? if (!$all_semesters) : ?> <h2> diff --git a/app/views/contents/courseware/index.php b/app/views/contents/courseware/index.php index 5682e67e5cb..c0d761d0dc3 100755 --- a/app/views/contents/courseware/index.php +++ b/app/views/contents/courseware/index.php @@ -1,43 +1,10 @@ -<div class="cw-content-projects"> - <? if (!empty($elements)): ?> - <ul class="cw-tiles"> - <? foreach ($elements as $element) :?> - <li class="tile <?= htmlReady($element['payload']['color'])?>"> - <a href="<?= URLHelper::getLink('dispatch.php/contents/courseware/courseware#/structural_element/'.$element['id']) ?>"> - <? if ($element->getImageUrl() === null) : ?> - <div class="preview-image default-image"></div> - <? else : ?> - <div class="preview-image" style="background-image: url(<?= htmlReady($element->getImageUrl()) ?>)" ></div> - <? endif; ?> - <div class="description"> - <header><?= htmlReady($element['title']) ?></header> - <div class="description-text-wrapper"> - <p> - <?= htmlReady($element['payload']['description']) ?> - </p> - </div> - <footer> - <?= sprintf(ngettext('%d Seite', '%d Seiten', $element->countChildren()), $element->countChildren()); ?> - </footer> - </div> - </a> - </li> - <? endforeach; ?> - </ul> - <? else : ?> - <div class="cw-contents-overview-teaser"> - <div class="cw-contents-overview-teaser-content"> - <header><?= _('Ihre persönlichen Lernmaterialien')?></header> - <p><?= _('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> - <a class="button" - href="<?= $controller->link_for('contents/courseware/create_project', []) ?>" - data-dialog="size=700" - title="<?= _('Neues Lernmaterial anlegen') ?>"> - <?= _('Neues Lernmaterial anlegen') ?> - </a> - </div> - </div> - <? endif; ?> +<script> + STUDIP.COURSEWARE_USERS_ROOT_ID = <?=$courseware_root->id ?> +</script> +<div + id="courseware-content-overview-app" + entry-type="users" + entry-id="<?= $user_id ?>" + licenses='<?= $licenses ?>' +> </div> diff --git a/app/views/contents/courseware/overview_action_widget.php b/app/views/contents/courseware/overview_action_widget.php new file mode 100755 index 00000000000..7f45e5e2298 --- /dev/null +++ b/app/views/contents/courseware/overview_action_widget.php @@ -0,0 +1 @@ +<aside id="courseware-content-overview-action-widget" class="widget-sidebar"></aside> \ No newline at end of file diff --git a/app/views/contents/courseware/overview_filter_widget.php b/app/views/contents/courseware/overview_filter_widget.php new file mode 100755 index 00000000000..b0f767799b0 --- /dev/null +++ b/app/views/contents/courseware/overview_filter_widget.php @@ -0,0 +1 @@ +<aside id="courseware-content-overview-filter-widget" class="widget-sidebar"></aside> \ No newline at end of file diff --git a/app/views/course/courseware/dashboard.php b/app/views/course/courseware/dashboard.php index d666b0d55fa..830cc90560d 100755 --- a/app/views/course/courseware/dashboard.php +++ b/app/views/course/courseware/dashboard.php @@ -1,9 +1,12 @@ - - - <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"></div> +<div + id="courseware-dashboard-app" + entry-type="courses" + entry-id="<?= Context::getId() ?>" +> +</div> diff --git a/app/views/course/courseware/dashboard_view_widget.php b/app/views/course/courseware/dashboard_view_widget.php new file mode 100755 index 00000000000..4e9075aac21 --- /dev/null +++ b/app/views/course/courseware/dashboard_view_widget.php @@ -0,0 +1 @@ +<aside id="courseware-dashboard-view-widget" class="widget-sidebar"></aside> \ No newline at end of file diff --git a/db/migrations/5.1.17_add_courseware_templates.php b/db/migrations/5.1.17_add_courseware_templates.php new file mode 100755 index 00000000000..bf758972c6a --- /dev/null +++ b/db/migrations/5.1.17_add_courseware_templates.php @@ -0,0 +1,34 @@ +<?php + +class AddCoursewareTemplates extends \Migration +{ + public function description() + { + return 'Create Courseware template database tables'; + } + + public function up() + { + $db = \DBManager::get(); + + $db->exec("CREATE TABLE `cw_templates` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `purpose` ENUM('content', 'template', 'oer', 'portfolio', 'draft', 'other') COLLATE latin1_bin, + `structure` MEDIUMTEXT NOT NULL, + `mkdate` int(11) NOT NULL, + `chdate` int(11) NOT NULL, + + PRIMARY KEY (`id`) + ) + "); + + } + + public function down() + { + $db = \DBManager::get(); + + $db->exec("DROP TABLE IF EXISTS `cw_templates`"); + } +} diff --git a/db/migrations/5.1.18_add_courseware_tasks.php b/db/migrations/5.1.18_add_courseware_tasks.php new file mode 100755 index 00000000000..2cd45945c9c --- /dev/null +++ b/db/migrations/5.1.18_add_courseware_tasks.php @@ -0,0 +1,78 @@ +<?php + +class AddCoursewareTasks extends \Migration +{ + public function description() + { + return 'Create Courseware Task database tables and settings'; + } + + public function up() + { + $db = \DBManager::get(); + + $db->exec("CREATE TABLE `cw_task_groups` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `seminar_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `lecturer_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `target_id` int(11) NOT NULL, + `task_template_id` int(11) NOT NULL, + `solver_may_add_blocks` tinyint(1) NOT NULL, + `title` varchar(255) NOT NULL, + `mkdate` int(11) NOT NULL, + `chdate` int(11) NOT NULL, + + PRIMARY KEY (`id`), + INDEX index_seminar_id (`seminar_id`), + INDEX index_lecturer_id (`lecturer_id`) + ) + "); + + $db->exec("CREATE TABLE `cw_tasks` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `task_group_id` int(11) NOT NULL, + `structural_element_id` int(11) NOT NULL, + `solver_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `solver_type` ENUM('autor', 'group') COLLATE latin1_bin, + `submission_date` int(11) NOT NULL, + `submitted` tinyint(1) NOT NULL, + `renewal` ENUM('pending', 'granted', 'declined') COLLATE latin1_bin, + `renewal_date` int(11) NOT NULL, + `feedback_id` int(11) NULL DEFAULT NULL, + `mkdate` int(11) NOT NULL, + `chdate` int(11) NOT NULL, + + PRIMARY KEY (`id`), + INDEX index_task_group_id (`task_group_id`), + INDEX index_structural_element_id (`structural_element_id`), + INDEX index_solver_id (`solver_id`) + ) + "); + + $db->exec("CREATE TABLE `cw_task_feedbacks` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `task_id` int(11) NOT NULL, + `lecturer_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `content` MEDIUMTEXT NOT NULL, + `mkdate` int(11) NOT NULL, + `chdate` int(11) NOT NULL, + + PRIMARY KEY (`id`), + INDEX index_task_id (`task_id`), + INDEX index_lecturer_id (`lecturer_id`) + ) + "); + + $db->exec("ALTER TABLE `cw_structural_elements` + CHANGE `purpose` `purpose` ENUM('content','draft','task','template','oer','other','portfolio') + CHARACTER SET latin1 COLLATE latin1_bin NULL DEFAULT NULL;" + ); + } + + public function down() + { + $db = \DBManager::get(); + + $db->exec("DROP TABLE IF EXISTS `cw_tasks`, `cw_task_feedbacks`"); + } +} diff --git a/db/migrations/5.1.19_add_courseware_structural_element_discussion.php b/db/migrations/5.1.19_add_courseware_structural_element_discussion.php new file mode 100755 index 00000000000..e10f52dbf6e --- /dev/null +++ b/db/migrations/5.1.19_add_courseware_structural_element_discussion.php @@ -0,0 +1,49 @@ +<?php + +class AddCoursewareStructuralElementDiscussion extends \Migration +{ + public function description() + { + return 'Create Courseware structural element database tables for discussions'; + } + + public function up() + { + $db = \DBManager::get(); + + $db->exec("CREATE TABLE `cw_structural_element_comments` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `structural_element_id` int(11) NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `comment` MEDIUMTEXT NOT NULL, + `mkdate` int(11) NOT NULL, + `chdate` int(11) NOT NULL, + + PRIMARY KEY (`id`), + INDEX index_structural_element_id (`structural_element_id`), + INDEX index_user_id (`user_id`) + ) + "); + + $db->exec("CREATE TABLE `cw_structural_element_feedbacks` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `structural_element_id` int(11) NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `feedback` MEDIUMTEXT NOT NULL, + `mkdate` int(11) NOT NULL, + `chdate` int(11) NOT NULL, + + PRIMARY KEY (`id`), + INDEX index_structural_element_id (`structural_element_id`), + INDEX index_user_id (`user_id`) + ) + "); + } + + public function down() + { + $db = \DBManager::get(); + + $db->exec("DROP TABLE IF EXISTS `cw_structural_element_feedbacks`, `cw_structural_element_comments`"); + } +} \ No newline at end of file diff --git a/lib/activities/Activity.php b/lib/activities/Activity.php index b8ed6624ecf..b46c9b23be4 100644 --- a/lib/activities/Activity.php +++ b/lib/activities/Activity.php @@ -143,6 +143,7 @@ class Activity extends \SimpleORMap 'passed' => _('bestand %s'), 'shared' => _('teilte %s'), 'sent' => _('sendete %s'), + 'set' => _('stellte %s'), 'voided' => _('löschte %s') ]; diff --git a/lib/activities/ActivityObserver.php b/lib/activities/ActivityObserver.php index 80652cf8920..5bfd4cc6fef 100644 --- a/lib/activities/ActivityObserver.php +++ b/lib/activities/ActivityObserver.php @@ -39,5 +39,39 @@ class ActivityObserver //Notifications for ScheduleProvider (Course) \NotificationCenter::addObserver('\Studip\Activity\ScheduleProvider', 'postActivity','CourseDidChangeSchedule'); + + + // Notifications for CoursewareProvider + foreach ( + [ + \Courseware\Block::class, + \Courseware\BlockComment::class, + \Courseware\BlockFeedback::class, + \Courseware\StructuralElementComment::class, + \Courseware\StructuralElement::class, + \Courseware\StructuralElementFeedback::class, + \Courseware\Task::class, + \Courseware\TaskFeedback::class, + ] as $class + ) { + \NotificationCenter::addObserver( + \Studip\Activity\CoursewareProvider::class, + 'postActivity', + $class . 'DidCreate' + ); + } + + foreach ( + [ + \Courseware\Block::class, + \Courseware\TaskFeedback::class + ] as $class + ) { + \NotificationCenter::addObserver( + \Studip\Activity\CoursewareProvider::class, + 'postActivity', + $class . 'DidUpdate' + ); + } } } diff --git a/lib/activities/Context.php b/lib/activities/Context.php index 3f84452cef3..9f7978f521f 100644 --- a/lib/activities/Context.php +++ b/lib/activities/Context.php @@ -10,6 +10,24 @@ namespace Studip\Activity; abstract class Context { + public static $objectTypes = [ + 'documents', + 'message', + 'news', + 'participants', + 'schedule', + 'wiki', + 'courseware', + 'forum' + ]; + + public static $contexTypes = [ + 'system', + 'course', + 'institute', + 'user' + ]; + protected $provider, $observer; @@ -63,6 +81,24 @@ abstract class Context public function getActivities(Filter $filter) { $providers = $this->filterProvider($this->getProvider(), $filter); + + $query = 'context = ? AND context_id = ? AND mkdate >= ? AND mkdate <= ? ORDER BY mkdate DESC'; + $params = [$this->getContextType(), $this->getRangeId(), $filter->getStartDate(), $filter->getEndDate()]; + + if ($filter->getContext() !== null && $filter->getContextId() !== null) { + $params = [$filter->getContext(), $filter->getContextId(), $filter->getStartDate(), $filter->getEndDate()]; + } + + if(\in_array($filter->getObjectType(), $this::$objectTypes)) { + $query = 'object_type = ? AND ' . $query; + \array_unshift($params, $filter->getObjectType()); + + //Object ID Filter only available when object type is set + if($filter->getObjectId() !== null && \strlen($filter->getObjectId()) > 0) { + $query = 'object_id = ? AND ' . $query; + \array_unshift($params, $filter->getObjectId()); + } + } $activities = Activity::findAndMapBySQL( function ($activity) use ($providers) { if (isset($providers[$activity->provider])) { // provider is available @@ -72,9 +108,9 @@ abstract class Context } } }, - 'context = ? AND context_id = ? AND mkdate >= ? AND mkdate <= ? ORDER BY mkdate DESC' - , - [$this->getContextType(), $this->getRangeId(), $filter->getStartDate(), $filter->getEndDate()]); + $query, + $params + ); return array_filter($activities); } diff --git a/lib/activities/CourseContext.php b/lib/activities/CourseContext.php index 382284748bd..d09a508ac6e 100644 --- a/lib/activities/CourseContext.php +++ b/lib/activities/CourseContext.php @@ -52,6 +52,9 @@ class CourseContext extends Context } //news $this->addProvider('Studip\Activity\NewsProvider'); + + //courseware + $this->addProvider('Studip\Activity\CoursewareProvider'); } return $this->provider; diff --git a/lib/activities/CoursewareProvider.php b/lib/activities/CoursewareProvider.php index 5e5faaec948..335f7bb665c 100755 --- a/lib/activities/CoursewareProvider.php +++ b/lib/activities/CoursewareProvider.php @@ -2,29 +2,43 @@ namespace Studip\Activity; +use Courseware\Block; +use Courseware\BlockComment; +use Courseware\BlockFeedback; +use Courseware\Container; +use Courseware\StructuralElement; +use Courseware\StructuralElementComment; +use Courseware\StructuralElementFeedback; +use Courseware\Task; +use Courseware\TaskFeedback; class CoursewareProvider implements ActivityProvider { - public function getActivityDetails($activity) { - $structural_element = \Courseware\StructuralElement::find($activity->object_id); + $structural_element = StructuralElement::find($activity->object_id); if (!$structural_element) { return false; } - $payload = json_decode($structural_element['payload']); - $activity->content = formatReady($payload['description']); + $activity->content = formatReady($activity->getValue('content')); - if ($activity->context == "course") { - $url = \URLHelper::getURL('dispatch.php/course/courseware/?cid='). $activity->context_id . '#/structural_element/' . $structural_element->id; + if ($activity->context == 'course') { + $url = + \URLHelper::getURL('dispatch.php/course/courseware/?cid=') . + $activity->context_id . + '#/structural_element/' . + $structural_element->id; $activity->object_url = [ - $url => _('Zur Courseware in der Veranstaltung') + $url => _('Zur Courseware in der Veranstaltung'), ]; - } elseif ($activity->context == "user") { - $url = \URLHelper::getURL('dispatch.php/contents/my_contents'). '#/structural_element/' . $structural_element->id; + } elseif ($activity->context == 'user') { + $url = + \URLHelper::getURL('dispatch.php/contents/my_contents') . + '#/structural_element/' . + $structural_element->id; $activity->object_url = [ - $url => _('Zur eigenen Courseware') + $url => _('Zur eigenen Courseware'), ]; } @@ -33,6 +47,212 @@ class CoursewareProvider implements ActivityProvider public static function getLexicalField() { - return _('eine Courseware-Aktivität'); + return _('einen Courseware-Inhalt'); + } + + /** + * TODO + * + * @param String $event a notification for an activity + * @param \SimpleORMap $resource + */ + 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; + + 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'] != '') + ) { + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => null, + 'actor_type' => 'user', + 'actor_id' => $resource->editor_id, + 'verb' => 'edited', + '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 + */ + $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 StructuralElement::class . 'DidCreate': + /** + * @var \Courseware\StructuralElement $resource + */ + if ($resource->range_type === 'courses') { + $data = [ + 'provider' => self::class, + 'context' => $resource->range_type, + 'context_id' => $resource->range_id, + 'content' => null, + 'actor_type' => 'user', + 'actor_id' => $resource->owner_id, + 'verb' => 'created', + '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 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 Task::class . 'DidCreate': + /** + * @var \Courseware\Task $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $structuralElement = $resource['structural_element']; + if ($structuralElement->range_type === 'courses') { + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => null, + 'actor_type' => 'user', + 'actor_id' => $resource->task_group->lecturer_id, + 'verb' => 'set', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + } + break; + + case TaskFeedback::class . 'DidCreate': + /** + * @var \Courseware\TaskFeedback $resource + * @var \Courseware\StructuralElement $structuralElement + */ + $structuralElement = $resource->getStructuralElement(); + if ($structuralElement->range_type === 'courses') { + $data = [ + 'provider' => self::class, + 'context' => $structuralElement->range_type, + 'context_id' => $structuralElement->range_id, + 'content' => $resource->content, + 'actor_type' => 'user', + 'actor_id' => $resource->lecturer_id, + 'verb' => 'answered', + 'object_id' => $structuralElement->id, + 'object_type' => 'courseware', + 'mkdate' => time(), + ]; + } + break; + } + + if ($data) { + Activity::create($data); + } } -} \ No newline at end of file +} diff --git a/lib/activities/Filter.php b/lib/activities/Filter.php index 8e1947e5be1..e904f584d8d 100644 --- a/lib/activities/Filter.php +++ b/lib/activities/Filter.php @@ -13,7 +13,11 @@ class Filter $start_date, $end_date, $type, - $verb; + $verb, + $objectType, + $objectId, + $context, + $contextId; /** * @@ -86,4 +90,76 @@ class Filter { $this->verb = $verb; } + + /** + * + * @return string + */ + public function getObjectType() + { + return $this->objectType; + } + + /** + * + * @param string $objectType + */ + public function setObjectType($objectType) + { + $this->objectType = $objectType; + } + + /** + * + * @return string + */ + public function getObjectId() + { + return $this->objectId; + } + + /** + * + * @param string $objectId + */ + public function setObjectId($objectId) + { + $this->objectId = $objectId; + } + + /** + * + * @return string + */ + public function getContext() + { + return $this->context; + } + + /** + * + * @param string $context + */ + public function setContext($context) + { + $this->context = $context; + } + + /** + * + * @return string + */ + public function getContextId() + { + return $this->contextId; + } + + /** + * + * @param string $contextId + */ + public function setContextId($contextId) + { + $this->contextId = $contextId; + } } diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index a577f8bd0b9..408e57286fc 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -307,6 +307,13 @@ class RouteMap ); $group->get('/courseware-instances/{id}/bookmarks', Routes\Courseware\BookmarkedStructuralElementsIndex::class); + $group->get('/users/{id}/courseware-bookmarks', Routes\Courseware\UsersBookmarkedStructuralElementsIndex::class); + $this->addRelationship( + $group, + '/users/{id}/relationships/courseware-bookmarks', + Routes\Courseware\Rel\UsersBookmarkedStructuralElements::class + ); + $group->get('/courseware-blocks/{id}', Routes\Courseware\BlocksShow::class); $group->post('/courseware-blocks', Routes\Courseware\BlocksCreate::class); $group->patch('/courseware-blocks/{id}', Routes\Courseware\BlocksUpdate::class); @@ -390,6 +397,18 @@ class RouteMap // not a JSON route $group->post('/courseware-structural-elements/{id}/copy', Routes\Courseware\StructuralElementsCopy::class); + $group->get('/courseware-structural-elements/{id}/comments', Routes\Courseware\StructuralElementCommentsOfStructuralElementsIndex::class); + $group->post('/courseware-structural-element-comments', Routes\Courseware\StructuralElementCommentsCreate::class); + $group->get('/courseware-structural-element-comments/{id}', Routes\Courseware\StructuralElementCommentsShow::class); + $group->patch('/courseware-structural-element-comments/{id}', Routes\Courseware\StructuralElementCommentsUpdate::class); + $group->delete('/courseware-structural-element-comments/{id}', Routes\Courseware\StructuralElementCommentsDelete::class); + + $group->get('/courseware-structural-elements/{id}/feedback', Routes\Courseware\StructuralElementFeedbackOfStructuralElementsIndex::class); + $group->post('/courseware-structural-element-feedback', Routes\Courseware\StructuralElementFeedbackCreate::class); + $group->get('/courseware-structural-element-feedback/{id}', Routes\Courseware\StructuralElementFeedbackShow::class); + $group->patch('/courseware-structural-element-feedback/{id}', Routes\Courseware\StructuralElementFeedbackUpdate::class); + $group->delete('/courseware-structural-element-feedback/{id}', Routes\Courseware\StructuralElementFeedbackDelete::class); + $group->get('/courseware-blocks/{id}/user-data-field', Routes\Courseware\UserDataFieldOfBlocksShow::class); $group->get('/courseware-user-data-fields/{id}', Routes\Courseware\UserDataFieldsShow::class); $group->patch('/courseware-user-data-fields/{id}', Routes\Courseware\UserDataFieldsUpdate::class); @@ -407,6 +426,27 @@ class RouteMap $group->get('/courseware-blocks/{id}/feedback', Routes\Courseware\BlockFeedbacksOfBlocksIndex::class); $group->post('/courseware-block-feedback', Routes\Courseware\BlockFeedbacksCreate::class); $group->get('/courseware-block-feedback/{id}', Routes\Courseware\BlockFeedbacksShow::class); + $group->patch('/courseware-block-feedback/{id}', Routes\Courseware\BlockFeedbacksUpdate::class); + $group->delete('/courseware-block-feedback/{id}', Routes\Courseware\BlockFeedbacksDelete::class); + + $group->get('/courseware-tasks/{id}', Routes\Courseware\TasksShow::class); + $group->get('/courseware-tasks', Routes\Courseware\TasksIndex::class); + $group->patch('/courseware-tasks/{id}', Routes\Courseware\TasksUpdate::class); + $group->delete('/courseware-tasks/{id}', Routes\Courseware\TasksDelete::class); + + $group->get('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsShow::class); + $group->post('/courseware-task-groups', Routes\Courseware\TaskGroupsCreate::class); + + $group->get('/courseware-task-feedback/{id}', Routes\Courseware\TaskFeedbackShow::class); + $group->post('/courseware-task-feedback', Routes\Courseware\TaskFeedbackCreate::class); + $group->patch('/courseware-task-feedback/{id}', Routes\Courseware\TaskFeedbackUpdate::class); + $group->delete('/courseware-task-feedback/{id}', Routes\Courseware\TaskFeedbackDelete::class); + + $group->get('/courseware-templates/{id}', Routes\Courseware\TemplatesShow::class); + $group->get('/courseware-templates', Routes\Courseware\TemplatesIndex::class); + $group->post('/courseware-templates', Routes\Courseware\TemplatesCreate::class); + $group->patch('/courseware-templates/{id}', Routes\Courseware\TemplatesUpdate::class); + $group->delete('/courseware-templates/{id}', Routes\Courseware\TemplatesDelete::class); } private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php index cfc15ce4dc2..04f955e52a3 100755 --- a/lib/classes/JsonApi/Routes/Courseware/Authority.php +++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php @@ -8,6 +8,10 @@ use Courseware\BlockFeedback; use Courseware\Container; use Courseware\Instance; use Courseware\StructuralElement; +use Courseware\Task; +use Courseware\TaskFeedback; +use Courseware\TaskGroup; +use Courseware\Template; use Courseware\UserDataField; use Courseware\UserProgress; use User; @@ -133,6 +137,21 @@ class Authority return self::canShowCoursewareInstance($user, $resource); } + public static function canAddBookmarkToAUser(User $actor, User $user) + { + return $actor->id === $user->id; + } + + public static function canModifyBookmarksOfAUser(User $actor, User $user) + { + return $actor->id === $user->id; + } + + public static function canIndexBookmarksOfAUser(User $actor, User $user) + { + return $actor->id === $user->id; + } + /** * @SuppressWarnings(PHPMD.Superglobals) */ @@ -183,8 +202,12 @@ class Authority public static function canUpdateBlockComment(User $user, BlockComment $resource) { - return $user->id == $resource->user_id; - // should dozent be able to update? + $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 + ); + return $user->id === $resource->user_id || $perm; } public static function canDeleteBlockComment(User $user, BlockComment $resource) @@ -216,4 +239,155 @@ class Authority { return self::canUploadStructuralElementsImage($user, $resource); } + + public static function canShowTaskGroup(User $user, TaskGroup $resource): bool + { + return $resource['lecturer_id'] === $user->id; + } + + public static function canShowTask(User $user, Task $resource): bool + { + return self::canUpdateTask($user, $resource); + } + + public static function canIndexTasks(User $user): bool + { + // TODO: filtered index permissions are handled in the route + return $GLOBALS['perm']->have_perm('root', $user->id); + } + + public static function canCreateTasks(User $user, StructuralElement $resource): bool + { + return $resource->hasEditingPermission($user); + } + + public static function canUpdateTask(User $user, Task $resource): bool + { + return $resource->canUpdate($user); + } + + public static function canDeleteTask(User $user, Task $resource): bool + { + return self::canCreateTasks($user, $resource['structural_element']); + } + + public static function canCreateTaskFeedback(User $user, Task $resource): bool + { + return self::canCreateTasks($user, $resource['structural_element']); + } + + public static function canShowTaskFeedback(User $user, Task $resource): bool + { + return self::canShowTask($user, $resource); + } + + public static function canUpdateTaskFeedback(User $user, Task $resource): bool + { + return self::canCreateTaskFeedback($user, $resource); + } + + public static function canDeleteTaskFeedback(User $user, Task $resource): bool + { + return self::canCreateTaskFeedback($user, $resource); + } + + + public static function canIndexStructuralElementComments(User $user, StructuralElement $resource) + { + return self::canShowStructuralElement($user, $resource); + } + + public static function canShowStructuralElementComment(User $user, StructuralElementComment $resource) + { + return self::canShowStructuralElement($user, $resource); + } + + public static function canCreateStructuralElementComment(User $user, StructuralElement $resource) + { + return self::canShowStructuralElement($user, $resource); + } + + public static function canUpdateStructuralElementComment(User $user, StructuralElementComment $resource) + { + if ($GLOBALS['perm']->have_perm('root')) { + return true; + } + + $perm = $GLOBALS['perm']->have_studip_perm( + $resource->structural_element->course->config->COURSEWARE_EDITING_PERMISSION, + $resource->structural_element->course->id, + $user->id + ); + + return $user->id == $resource->user_id || $perm; + } + + public static function canDeleteStructuralElementComment(User $user, StructuralElementComment $resource) + { + return self::canUpdateStructuralElementComment($user, $resource); + } + + public static function canIndexStructuralElementFeedback(User $user, StructuralElement $resource) + { + return self::canUpdateStructuralElement($user, $resource); + } + + public static function canCreateStructuralElementFeedback(User $user, StructuralElement $resource) + { + if ($GLOBALS['perm']->have_perm('root')) { + return true; + } + + $perm = $GLOBALS['perm']->have_studip_perm( + $resource->course->config->COURSEWARE_EDITING_PERMISSION, + $resource->course->id, + $user->id + ); + + return $perm; + } + + public static function canUpdateStructuralElementFeedback(User $user, StructuralElementComment $resource) + { + return self::canCreateStructuralElementFeedback($user, $resource->structural_element); + } + + public static function canShowStructuralElementFeedback(User $user, StructuralElementFeedback $resource) + { + return $resource->user_id === $user->id || self::canUpdateStructuralElement($resource->structural_element); + } + + public static function canDeleteStructuralElementFeedback(User $user, StructuralElementComment $resource) + { + return self::canUpdateStructuralElementFeedback($user, $resource); + } + + + public static function canShowTemplate(User $user, Template $resource) + { + // templates are for everybody, aren't they? + return true; + } + + public static function canIndexTemplates(User $user) + { + // templates are for everybody, aren't they? + return true; + } + + public static function canCreateTemplate(User $user) + { + return $GLOBALS['perm']->have_perm('admin'); + } + + public static function canUpdateTemplate(User $user, Template $resource) + { + return self::canCreateTemplate($user); + } + + public static function canDeleteTemplate(User $user, Template $resource) + { + return self::canCreateTemplate($user); + } + } diff --git a/lib/classes/JsonApi/Routes/Courseware/BlockCommentsCreate.php b/lib/classes/JsonApi/Routes/Courseware/BlockCommentsCreate.php index eec88186d9f..07034cf3c8c 100755 --- a/lib/classes/JsonApi/Routes/Courseware/BlockCommentsCreate.php +++ b/lib/classes/JsonApi/Routes/Courseware/BlockCommentsCreate.php @@ -9,6 +9,9 @@ use JsonApi\Schemas\Courseware\Block as BlockSchema; use JsonApi\Schemas\Courseware\BlockComment as BlockCommentSchema; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; +use Studip\Activity\CoursewareProvider; +use Courseware\Container; /** * Create a comment on a block. diff --git a/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksCreate.php b/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksCreate.php index 10c4759dafe..05e197460be 100755 --- a/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksCreate.php +++ b/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksCreate.php @@ -2,6 +2,7 @@ namespace JsonApi\Routes\Courseware; +use Courseware\Container; use JsonApi\Errors\AuthorizationFailedException; use JsonApi\JsonApiController; use JsonApi\Routes\ValidationTrait; @@ -9,6 +10,8 @@ use JsonApi\Schemas\Courseware\Block as BlockSchema; use JsonApi\Schemas\Courseware\BlockFeedback as BlockFeedbackSchema; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; +use Studip\Activity\CoursewareProvider; /** * Create feedback on a block. diff --git a/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksDelete.php b/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksDelete.php new file mode 100755 index 00000000000..fa416e577b0 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksDelete.php @@ -0,0 +1,33 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\BlockFeedback; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\ConflictException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one comment on a block. + */ +class BlockFeedbacksDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = BlockFeedback::find($args['id']))) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeleteBlockFeedback($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksUpdate.php b/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksUpdate.php new file mode 100755 index 00000000000..51cc616478e --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/BlockFeedbacksUpdate.php @@ -0,0 +1,113 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\BlockFeedback; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\Block as BlockSchema; +use JsonApi\Schemas\Courseware\BlockFeedback as BlockFeedbackSchema; +use JsonApi\Schemas\User as UserSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Update a feedback on a block + */ +class BlockFeedbacksUpdate extends JsonApiController +{ + use ValidationTrait, UserProgressesHelper; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = BlockFeedback::find($args['id']))) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + if (!Authority::canUpdateBlockFeedback($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $blockFeedback = $this->updateBlockFeedback($json, $resource); + + return $this->getContentResponse($blockFeedback); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * @SuppressWarnings(CyclomaticComplexity) + * @SuppressWarnings(NPathComplexity) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (BlockFeedbackSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + if (self::arrayGet($json, 'data.id') !== $data->id) { + return 'Mismatch in document `id`.'; + } + + if (!($feedback = self::arrayGet($json, 'data.attributes.feedback'))) { + return 'Missing `feedback` attribute.'; + } + if (!is_string($feedback)) { + return 'Attribute `feedback` must be a string.'; + } + if ($feedback == '') { + return 'Attribute `feedback` must not be empty.'; + } + + if (self::arrayHas($json, 'data.relationships.user')) { + if (!($user = $this->getUserFromJson($json))) { + return 'Invalid `user` relationship.'; + } + if ($user->id !== $data['user_id']) { + return 'Cannot update `user` relationship.'; + } + } + + if (self::arrayHas($json, 'data.relationships.block')) { + if (!($block = $this->getBlockFromJson($json))) { + return 'Invalid `block` relationship.'; + } + if ($block->id !== $data['block_id']) { + return 'Cannot update `block` relationship.'; + } + } + } + + private function getBlockFromJson($json) + { + if (!$this->validateResourceObject($json, 'data.relationships.block', BlockSchema::TYPE)) { + return null; + } + $blockId = self::arrayGet($json, 'data.relationships.block.data.id'); + + return \Courseware\Block::find($blockId); + } + + private function getUserFromJson($json) + { + if (!$this->validateResourceObject($json, 'data.relationships.user', UserSchema::TYPE)) { + return null; + } + $userId = self::arrayGet($json, 'data.relationships.user.data.id'); + + return \User::find($userId); + } + + private function updateBlockFeedback(array $json, \Courseware\BlockFeedback $resource) + { + $resource->feedback = self::arrayGet($json, 'data.attributes.feedback', ''); + $resource->store(); + + return $resource; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/BlocksCreate.php b/lib/classes/JsonApi/Routes/Courseware/BlocksCreate.php index c29020ca6e6..04c7d928b1f 100755 --- a/lib/classes/JsonApi/Routes/Courseware/BlocksCreate.php +++ b/lib/classes/JsonApi/Routes/Courseware/BlocksCreate.php @@ -11,6 +11,8 @@ use JsonApi\Schemas\Courseware\Block as BlockSchema; use JsonApi\Schemas\Courseware\Container as ContainerSchema; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; +use Studip\Activity\CoursewareProvider; /** * Create a block in a container. diff --git a/lib/classes/JsonApi/Routes/Courseware/BlocksUpdate.php b/lib/classes/JsonApi/Routes/Courseware/BlocksUpdate.php index 09eb9b2b892..fcec2bb3697 100755 --- a/lib/classes/JsonApi/Routes/Courseware/BlocksUpdate.php +++ b/lib/classes/JsonApi/Routes/Courseware/BlocksUpdate.php @@ -3,6 +3,7 @@ namespace JsonApi\Routes\Courseware; use Courseware\Block; +use Courseware\Container; use JsonApi\Errors\AuthorizationFailedException; use JsonApi\Errors\RecordNotFoundException; use JsonApi\Errors\UnprocessableEntityException; @@ -11,6 +12,8 @@ use JsonApi\Routes\ValidationTrait; use JsonApi\Schemas\Courseware\Block as BlockSchema; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; +use Studip\Activity\CoursewareProvider; /** * Update one Block. diff --git a/lib/classes/JsonApi/Routes/Courseware/BookmarkedStructuralElementsIndex.php b/lib/classes/JsonApi/Routes/Courseware/BookmarkedStructuralElementsIndex.php index a9b3c094d3e..51a2a017c8f 100755 --- a/lib/classes/JsonApi/Routes/Courseware/BookmarkedStructuralElementsIndex.php +++ b/lib/classes/JsonApi/Routes/Courseware/BookmarkedStructuralElementsIndex.php @@ -46,6 +46,6 @@ class BookmarkedStructuralElementsIndex extends JsonApiController $total = count($resources); list($offset, $limit) = $this->getOffsetAndLimit(); - return $this->getPaginatedResponse(array_slice($resources, $offset, $limit), $total); + return $this->getPaginatedContentResponse(array_slice($resources, $offset, $limit), $total); } } diff --git a/lib/classes/JsonApi/Routes/Courseware/ContainersUpdate.php b/lib/classes/JsonApi/Routes/Courseware/ContainersUpdate.php index ae6a4e6960d..77d3d2c75b2 100755 --- a/lib/classes/JsonApi/Routes/Courseware/ContainersUpdate.php +++ b/lib/classes/JsonApi/Routes/Courseware/ContainersUpdate.php @@ -73,6 +73,8 @@ class ContainersUpdate extends JsonApiController ); } + $resource->position = $json['data']['attributes']['position']; + $resource->editor_id = $user->id; $resource->store(); diff --git a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php index d22d9e3f17b..843f7c2a902 100755 --- a/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php +++ b/lib/classes/JsonApi/Routes/Courseware/CoursewareInstancesHelper.php @@ -11,7 +11,10 @@ trait CoursewareInstancesHelper { private function findInstance(string $instanceId): Instance { - list($rangeType, $rangeId) = explode('_', $instanceId); + [$rangeType, $rangeId] = explode('_', $instanceId); + if (!is_string($rangeType) || !is_string($rangeId)) { + throw new BadRequestException('Invalid instance id: "' . $instanceId . '".'); + } return $this->findInstanceWithRange($rangeType, $rangeId); } diff --git a/lib/classes/JsonApi/Routes/Courseware/Rel/BookmarkedStructuralElements.php b/lib/classes/JsonApi/Routes/Courseware/Rel/BookmarkedStructuralElements.php index 7d191f53ccf..1e68624b153 100755 --- a/lib/classes/JsonApi/Routes/Courseware/Rel/BookmarkedStructuralElements.php +++ b/lib/classes/JsonApi/Routes/Courseware/Rel/BookmarkedStructuralElements.php @@ -169,6 +169,10 @@ class BookmarkedStructuralElements extends RelationshipsController private function addBookmarks(\User $user, array $newIds): void { foreach ($newIds as $structuralElementId) { + if (Bookmark::countBySQL('user_id = ? AND element_id = ?', [$user->id, $structuralElementId])) { + continue; + } + Bookmark::create(['user_id' => $user->id, 'element_id' => $structuralElementId]); } } diff --git a/lib/classes/JsonApi/Routes/Courseware/Rel/UsersBookmarkedStructuralElements.php b/lib/classes/JsonApi/Routes/Courseware/Rel/UsersBookmarkedStructuralElements.php new file mode 100755 index 00000000000..7f05c66a648 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/Rel/UsersBookmarkedStructuralElements.php @@ -0,0 +1,180 @@ +<?php + +namespace JsonApi\Routes\Courseware\Rel; + +use Courseware\Bookmark; +use Courseware\Instance; +use Courseware\StructuralElement; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\Errors\ConflictException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Routes\Courseware\Authority; +use JsonApi\Routes\Courseware\CoursewareInstancesHelper; +use JsonApi\Routes\RelationshipsController; +use Psr\Http\Message\ServerRequestInterface as Request; + +class UsersBookmarkedStructuralElements extends RelationshipsController +{ + use CoursewareInstancesHelper; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function fetchRelationship(Request $request, $related) + { + $bookmarks = array_column(Bookmark::findUsersBookmarks($related), 'element'); + $total = count($bookmarks); + list($offset, $limit) = $this->getOffsetAndLimit(); + $page = array_slice($bookmarks, $offset, $limit); + + return $this->getPaginatedIdentifiersResponse($page, $total); + } + + protected function replaceRelationship(Request $request, $related) + { + $json = $this->validate($request); + $structuralElements = $this->validateStructuralElements($user = $this->getUser($request), $json, $related); + $this->replaceBookmarks($related, $structuralElements); + + return $this->getCodeResponse(204); + } + + protected function addToRelationship(Request $request, $related) + { + $json = $this->validate($request); + $structuralElements = $this->validateStructuralElements($user = $this->getUser($request), $json, $related); + $this->addBookmarks($related, $structuralElements); + + return $this->getCodeResponse(204); + } + + protected function removeFromRelationship(Request $request, $related) + { + $json = $this->validate($request); + $structuralElements = $this->validateStructuralElements($user = $this->getUser($request), $json, $related); + $this->removeBookmarks($user, $structuralElements); + + return $this->getCodeResponse(204); + } + + protected function findRelated(array $args) + { + if (!($related = \User::find($args['id']))) { + throw new RecordNotFoundException(); + } + + return $related; + } + + protected function authorize(Request $request, $resource) + { + $observer = $this->getUser($request); + $observed = $resource; + switch ($request->getMethod()) { + case 'GET': + return Authority::canIndexBookmarksOfAUser($observer, $observed); + + case 'DELETE': + case 'PATCH': + case 'POST': + return Authority::canModifyBookmarksOfAUser($observer, $observed); + + default: + return false; + } + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function getRelationshipSelfLink($resource, $schema, $userData) + { + return $schema->getRelationshipSelfLink($resource, \JsonApi\Schemas\User::REL_COURSEWARE_BOOKMARKS); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function getRelationshipRelatedLink($resource, $schema, $userData) + { + return $schema->getRelationshipRelatedLink($resource, \JsonApi\Schemas\User::REL_COURSEWARE_BOOKMARKS); + } + + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + + $data = self::arrayGet($json, 'data'); + + if (!is_array($data)) { + return 'Document´s ´data´ must be an array.'; + } + + foreach ($data as $item) { + if (\JsonApi\Schemas\Courseware\StructuralElement::TYPE !== self::arrayGet($item, 'type')) { + return 'Wrong `type` in document´s `data`.'; + } + + if (!self::arrayGet($item, 'id')) { + return 'Missing `id` of document´s `data`.'; + } + } + + if (self::arrayHas($json, 'data.attributes')) { + return 'Document must not have `attributes`.'; + } + } + + private function validateStructuralElements(\User $actor, $json, \User $user) + { + $structuralElements = []; + + foreach (self::arrayGet($json, 'data') as $structuralElementResource) { + if (!($structuralElement = StructuralElement::find($structuralElementResource['id']))) { + throw new RecordNotFoundException(); + } + + if (!Authority::canModifyBookmarksOfAUser($actor, $user)) { + throw new AuthorizationFailedException(); + } + + if (!Authority::canShowStructuralElement($user, $structuralElement)) { + throw new RecordNotFoundException(); + } + + $structuralElements[] = $structuralElement->id; + } + + return $structuralElements; + } + + private function replaceBookmarks(\User $user, array $newIds) + { + $oldIds = array_column(Bookmark::findUsersBookmarks($user), 'element_id'); + $onlyInOld = array_diff($oldIds, $newIds); + $onlyInNew = array_diff($newIds, $oldIds); + + $this->removeBookmarks($user, $onlyInOld); + $this->addBookmarks($user, $onlyInNew); + } + + private function addBookmarks(\User $user, array $newIds): void + { + foreach ($newIds as $structuralElementId) { + if (Bookmark::countBySQL('user_id = ? AND element_id = ?', [$user->id, $structuralElementId])) { + continue; + } + Bookmark::create(['user_id' => $user->id, 'element_id' => $structuralElementId]); + } + } + + private function removeBookmarks(\User $user, array $oldIds): void + { + Bookmark::deleteBySQL('user_id = ? AND element_id IN (?)', [$user->id, $oldIds]); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsCreate.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsCreate.php new file mode 100755 index 00000000000..9dfa77bd091 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsCreate.php @@ -0,0 +1,86 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\StructuralElement as StructuralElementSchema; +use JsonApi\Schemas\Courseware\StructuralElementComment as StructuralElementCommentSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; +use Studip\Activity\CoursewareProvider; +use Courseware\Container; + +/** + * Create a comment on a StructuralElement. + */ +class StructuralElementCommentsCreate extends JsonApiController +{ + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $structuralElement = $this->getStructuralElementFromJson($json); + if (!Authority::canCreateStructuralElementComment($user = $this->getUser($request), $structuralElement)) { + throw new AuthorizationFailedException(); + } + $structuralElementComment = $this->createStructuralElementComment($user, $json, $structuralElement); + + return $this->getCreatedResponse($structuralElementComment); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (StructuralElementCommentSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + if (!self::arrayHas($json, 'data.attributes.comment')) { + return 'Missing `comment` attribute.'; + } + + if (!self::arrayHas($json, 'data.relationships.structural-element')) { + return 'Missing `structural-element` relationship.'; + } + if (!$this->getStructuralElementFromJson($json)) { + return 'Invalid `structural-element` relationship.'; + } + } + + private function getStructuralElementFromJson($json) + { + if (!$this->validateResourceObject($json, 'data.relationships.structural-element', StructuralElementSchema::TYPE)) { + return null; + } + $structuralElementId = self::arrayGet($json, 'data.relationships.structural-element.data.id'); + + return \Courseware\StructuralElement::find($structuralElementId); + } + + private function createStructuralElementComment(\User $user, array $json, \Courseware\StructuralElement $structuralElement) + { + $structuralElementComment = \Courseware\StructuralElementComment::build([ + 'structural_element_id' => $structuralElement->id, + 'user_id' => $user->id, + 'comment' => self::arrayGet($json, 'data.attributes.comment', ''), + ]); + $structuralElementComment->store(); + + return $structuralElementComment; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsDelete.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsDelete.php new file mode 100755 index 00000000000..6ea097c4c11 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsDelete.php @@ -0,0 +1,33 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElementComment; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\ConflictException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one comment on a structural element. + */ +class StructuralElementCommentsDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = StructuralElementComment::find($args['id']))) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeleteStructuralElementComment($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsOfStructuralElementsIndex.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsOfStructuralElementsIndex.php new file mode 100755 index 00000000000..95159667b8d --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsOfStructuralElementsIndex.php @@ -0,0 +1,35 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElement; +use Courseware\StructuralElementComment; +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 user progress of a structural element. + */ +class StructuralElementCommentsOfStructuralElementsIndex extends JsonApiController +{ + protected $allowedIncludePaths = ['structural-element', 'user']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($structural_element = StructuralElement::find($args['id']))) { + throw new RecordNotFoundException(); + } + if (!Authority::canIndexStructuralElementComments($this->getUser($request), $structural_element)) { + throw new AuthorizationFailedException(); + } + $resources = StructuralElementComment::findBySql('structural_element_id = ?', [$structural_element->id]); + + return $this->getContentResponse($resources); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsShow.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsShow.php new file mode 100755 index 00000000000..6f334a92421 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsShow.php @@ -0,0 +1,34 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElementComment; +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 a comment on a structural element. + */ +class StructuralElementCommentsShow extends JsonApiController +{ + protected $allowedIncludePaths = ['structural-element', 'user']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = StructuralElementComment::find($args['id']))) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowStructuralElementComment($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsUpdate.php new file mode 100755 index 00000000000..5728847c219 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementCommentsUpdate.php @@ -0,0 +1,113 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElementComment; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\StructuralElement as StructuralElementSchema; +use JsonApi\Schemas\Courseware\StructuralElementComment as StructuralElementCommentSchema; +use JsonApi\Schemas\User as UserSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Update a comment on a structural element + */ +class StructuralElementCommentsUpdate extends JsonApiController +{ + use ValidationTrait, UserProgressesHelper; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = StructuralElementComment::find($args['id']))) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + if (!Authority::canUpdateStructuralElementComment($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $structuralElementComment = $this->updateStructuralElementComment($json, $resource); + + return $this->getContentResponse($structuralElementComment); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * @SuppressWarnings(CyclomaticComplexity) + * @SuppressWarnings(NPathComplexity) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (StructuralElementCommentSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + if (self::arrayGet($json, 'data.id') !== $data->id) { + return 'Mismatch in document `id`.'; + } + + if (!($comment = self::arrayGet($json, 'data.attributes.comment'))) { + return 'Missing `comment` attribute.'; + } + if (!is_string($comment)) { + return 'Attribute `comment` must be a string.'; + } + if ($comment == '') { + return 'Attribute `comment` must not be empty.'; + } + + if (self::arrayHas($json, 'data.relationships.user')) { + if (!($user = $this->getUserFromJson($json))) { + return 'Invalid `user` relationship.'; + } + if ($user->id !== $data['user_id']) { + return 'Cannot update `user` relationship.'; + } + } + + if (self::arrayHas($json, 'data.relationships.structural-element')) { + if (!($structural_element = $this->getStructuralElementFromJson($json))) { + return 'Invalid `structural-element` relationship.'; + } + if ($structural_element->id !== $data['structural_element_id']) { + return 'Cannot update `structural-element` relationship.'; + } + } + } + + private function getStructuralElementFromJson($json) + { + if (!$this->validateResourceObject($json, 'data.relationships.structural-element', StructuralElementSchema::TYPE)) { + return null; + } + $structuralElementId = self::arrayGet($json, 'data.relationships.structural-element.data.id'); + + return \Courseware\StructuralElement::find($structuralElementId); + } + + private function getUserFromJson($json) + { + if (!$this->validateResourceObject($json, 'data.relationships.user', UserSchema::TYPE)) { + return null; + } + $userId = self::arrayGet($json, 'data.relationships.user.data.id'); + + return \User::find($userId); + } + + private function updateStructuralElementComment(array $json, \Courseware\StructuralElementComment $resource) + { + $resource->comment = self::arrayGet($json, 'data.attributes.comment', ''); + $resource->store(); + + return $resource; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackCreate.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackCreate.php new file mode 100755 index 00000000000..bcf5425783d --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackCreate.php @@ -0,0 +1,86 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Container; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\StructuralElement as StructuralElementSchema; +use JsonApi\Schemas\Courseware\StructuralElementFeedback as StructuralElementFeedbackSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; +use Studip\Activity\CoursewareProvider; + +/** + * Create feedback on a structural-element. + */ +class StructuralElementFeedbackCreate extends JsonApiController +{ + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $structuralElement = $this->getStructuralElementFromJson($json); + if (!Authority::canCreateStructuralElementFeedback($user = $this->getUser($request), $structuralElement)) { + throw new AuthorizationFailedException(); + } + $structuralElementFeedback = $this->createStructuralElementFeedback($user, $json, $structuralElement); + + return $this->getCreatedResponse($structuralElementFeedback); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (StructuralElementFeedbackSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + + if (!self::arrayHas($json, 'data.attributes.feedback')) { + return 'Missing `feedback` attribute.'; + } + + if (!self::arrayHas($json, 'data.relationships.structural-element')) { + return 'Missing `structural-element` relationship.'; + } + if (!$this->getStructuralElementFromJson($json)) { + return 'Invalid `structural-element` relationship.'; + } + } + + private function getStructuralElementFromJson($json) + { + if (!$this->validateResourceObject($json, 'data.relationships.structural-element', StructuralElementSchema::TYPE)) { + return null; + } + $structuralElementId = self::arrayGet($json, 'data.relationships.structural-element.data.id'); + + return \Courseware\StructuralElement::find($structuralElementId); + } + + private function createStructuralElementFeedback(\User $user, array $json, \Courseware\StructuralElement $structuralElement) + { + $structuralElementFeedback = \Courseware\StructuralElementFeedback::build([ + 'structural_element_id' => $structuralElement->id, + 'user_id' => $user->id, + 'feedback' => self::arrayGet($json, 'data.attributes.feedback'), + ]); + $structuralElementFeedback->store(); + + return $structuralElementFeedback; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackDelete.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackDelete.php new file mode 100755 index 00000000000..5c61bad7874 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackDelete.php @@ -0,0 +1,33 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElementFeedback; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\ConflictException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Delete one comment on a structural element. + */ +class StructuralElementFeedbackDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = StructuralElementFeedback::find($args['id']))) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeleteStructuralElementFeedback($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackOfStructuralElementsIndex.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackOfStructuralElementsIndex.php new file mode 100755 index 00000000000..855ce60c4c6 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackOfStructuralElementsIndex.php @@ -0,0 +1,38 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElement; +use Courseware\StructuralElementFeedback; +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 feedback of a StructuralElement. + */ +class StructuralElementFeedbackOfStructuralElementsIndex extends JsonApiController +{ + protected $allowedIncludePaths = ['user', 'structural-element']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?Block $block */ + $structuralElement = StructuralElement::find($args['id']); + if (!$structuralElement) { + throw new RecordNotFoundException(); + } + if (!Authority::canIndexStructuralElementFeedback($this->getUser($request), $structuralElement)) { + throw new AuthorizationFailedException(); + } + /** @var StructuralElementFeedback[] $resources */ + $resources = $structuralElement->feedback; + + return $this->getContentResponse($resources); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackShow.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackShow.php new file mode 100755 index 00000000000..752e178f6ec --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackShow.php @@ -0,0 +1,36 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElementFeedback; +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 feedback on a StructuralElement. + */ +class StructuralElementFeedbackShow extends JsonApiController +{ + protected $allowedIncludePaths = ['user', 'structural-element']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?StructuralElementFeedback $resource */ + $resource = StructuralElementFeedback::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowStructuralElementFeedback($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackUpdate.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackUpdate.php new file mode 100755 index 00000000000..5d4b0afc325 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementFeedbackUpdate.php @@ -0,0 +1,113 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElementFeedback; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\StructuralElement as StructuralElementSchema; +use JsonApi\Schemas\Courseware\StructuralElementFeedback as StructuralElementFeedbackSchema; +use JsonApi\Schemas\User as UserSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Update a feedback on a structural-element + */ +class StructuralElementFeedbackUpdate extends JsonApiController +{ + use ValidationTrait, UserProgressesHelper; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = StructuralElementFeedback::find($args['id']))) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + if (!Authority::canUpdateStructuralElementFeedback($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $structuralElementFeedback = $this->updateStructuralElementFeedback($json, $resource); + + return $this->getContentResponse($structuralElementFeedback); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * @SuppressWarnings(CyclomaticComplexity) + * @SuppressWarnings(NPathComplexity) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (StructuralElementFeedbackSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + if (self::arrayGet($json, 'data.id') !== $data->id) { + return 'Mismatch in document `id`.'; + } + + if (!($feedback = self::arrayGet($json, 'data.attributes.feedback'))) { + return 'Missing `feedback` attribute.'; + } + if (!is_string($feedback)) { + return 'Attribute `feedback` must be a string.'; + } + if ($feedback == '') { + return 'Attribute `feedback` must not be empty.'; + } + + if (self::arrayHas($json, 'data.relationships.user')) { + if (!($user = $this->getUserFromJson($json))) { + return 'Invalid `user` relationship.'; + } + if ($user->id !== $data['user_id']) { + return 'Cannot update `user` relationship.'; + } + } + + if (self::arrayHas($json, 'data.relationships.structural-element')) { + if (!($structuralElement = $this->getStructuralElementFromJson($json))) { + return 'Invalid `structural-element` relationship.'; + } + if ($structuralElement->id !== $data['structural_element_id']) { + return 'Cannot update `structural-element` relationship.'; + } + } + } + + private function getStructuralElementFromJson($json) + { + if (!$this->validateResourceObject($json, 'data.relationships.structural-element', StructuralElementSchema::TYPE)) { + return null; + } + $structuralElementId = self::arrayGet($json, 'data.relationships.structural-element.data.id'); + + return \Courseware\StructuralElement::find($structuralElementId); + } + + private function getUserFromJson($json) + { + if (!$this->validateResourceObject($json, 'data.relationships.user', UserSchema::TYPE)) { + return null; + } + $userId = self::arrayGet($json, 'data.relationships.user.data.id'); + + return \User::find($userId); + } + + private function updateStructuralElementFeedback(array $json, \Courseware\StructuralElementFeedback $resource) + { + $resource->feedback = self::arrayGet($json, 'data.attributes.feedback', ''); + $resource->store(); + + return $resource; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php index 9622fb66ab1..5dcb6d47194 100755 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php @@ -33,6 +33,9 @@ class StructuralElementsCopy extends NonJsonApiController } $newElement = $this->copyElement($user, $sourceElement, $newParent); + if ($data['remove_purpose']) { + $newElement->purpose = ''; + } return $this->redirectToStructuralElement($response, $newElement); } diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCreate.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCreate.php index ed1374af382..8f1c8bd8443 100755 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCreate.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCreate.php @@ -8,6 +8,7 @@ use JsonApi\Routes\ValidationTrait; use JsonApi\Schemas\Courseware\StructuralElement as StructuralElementSchema; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; /** * Create a block in a container. @@ -78,11 +79,49 @@ class StructuralElementsCreate extends JsonApiController 'editor_id' => $user->id, 'edit_blocker_id' => '', 'title' => self::arrayGet($json, 'data.attributes.title', ''), - 'purpose' => 'CONTENT', + 'purpose' => self::arrayGet($json, 'data.attributes.purpose', 'content'), + 'payload' => self::arrayGet($json, 'data.attributes.payload', ''), 'position' => $parent->countChildren() ]); $struct->store(); + $template = \Courseware\Template::find(self::arrayGet($json, 'data.templateId')); + + if ($template) { + $structure = json_decode($template->structure, true); + + foreach($structure['containers'] as $container) { + + $new_container = \Courseware\Container::build([ + 'structural_element_id' => $struct->id, + 'owner_id' => $user->id, + 'editor_id' => $user->id, + 'edit_blocker_id' => '', + 'position' => $struct->countContainers(), + 'container_type' => $container['attributes']['container-type'], + 'payload' => json_encode($container['attributes']['payload']), + ]); + + $new_container->store(); + $blockMap = []; + foreach($container['blocks'] as $block) { + $new_block = \Courseware\Block::build([ + 'container_id' => $new_container->id, + 'owner_id' => $user->id, + 'editor_id' => $user->id, + 'position' => $new_container->countBlocks(), + 'block_type' => $block['attributes']['block-type'], + 'payload' => json_encode($block['attributes']['payload']), + 'visible' => 1, + ]); + + $new_block->store(); + $blockMap[$block['id']] = $new_block->id; + } + $new_container['payload'] = $new_container->type->copyPayload($blockMap); + $new_container->store(); + } + } return $struct; } diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackCreate.php b/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackCreate.php new file mode 100755 index 00000000000..e7d75527797 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackCreate.php @@ -0,0 +1,89 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Task; +use Courseware\TaskFeedback; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\UnprocessableEntityException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use JsonApi\Schemas\Courseware\TaskFeedback as TaskFeedbackSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; +use Studip\Activity\CoursewareProvider; + +/** + * Create a Task. + */ +class TaskFeedbackCreate extends JsonApiController +{ + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $task = $this->getTaskFromJson($json); + if (!Authority::canCreateTaskFeedback($lecturer = $this->getUser($request), $task)) { + throw new AuthorizationFailedException(); + } + + $feedback = $this->createTaskFeedback($lecturer, $json, $task); + + return $this->getCreatedResponse($feedback); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (TaskFeedbackSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + if (!$this->getTaskFromJson($json)) { + return 'Invalid `task` relationship.'; + } + } + + private function getTaskFromJson($json) + { + if (!$this->validateResourceObject($json, 'data.relationships.task', TaskSchema::TYPE)) { + return null; + } + + $taskId = self::arrayGet($json, 'data.relationships.task.data.id'); + + return \Courseware\Task::find($taskId); + } + + private function createTaskFeedback(\User $lecturer, array $json, \Courseware\Task $task): TaskFeedback + { + $get = function ($key, $default = '') use ($json) { + return self::arrayGet($json, $key, $default); + }; + + $feedback = TaskFeedback::build([ + 'lecturer_id' => $lecturer->id, + 'task_id' => $task->id, + 'content' => self::arrayGet($json, 'data.attributes.content', '') + ]); + + $feedback->store(); + $task->feedback_id = $feedback->id; + $task->store(); + + return $feedback; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackDelete.php b/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackDelete.php new file mode 100755 index 00000000000..b7459f84320 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackDelete.php @@ -0,0 +1,36 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Task; +use Courseware\TaskFeedback; +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 Task. + */ +class TaskFeedbackDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = TaskFeedback::find($args['id']))) { + throw new RecordNotFoundException(); + } + $task = Task::find($resource->task_id); + if (!Authority::canDeleteTaskFeedback($user = $this->getUser($request), $task)) { + throw new AuthorizationFailedException(); + } + $task->feedback_id = null; + $task->store(); + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackShow.php b/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackShow.php new file mode 100755 index 00000000000..b54011876a4 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackShow.php @@ -0,0 +1,38 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Task; +use Courseware\TaskFeedback; +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 one Task. + */ +class TaskFeedbackShow extends JsonApiController +{ + protected $allowedIncludePaths = [ + 'lecturer', + 'task' + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = TaskFeedback::find($args['id']))) { + throw new RecordNotFoundException(); + } + $task = Task::find($resource->task_id); + if (!Authority::canShowTaskFeedback($this->getUser($request), $task)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackUpdate.php new file mode 100755 index 00000000000..40978182128 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskFeedbackUpdate.php @@ -0,0 +1,84 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Task; +use Courseware\TaskFeedback; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Errors\UnprocessableEntityException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Studip\Activity\Activity; +use Studip\Activity\CoursewareProvider; + +/** + * Update one Task. + */ +class TaskFeedbackUpdate extends JsonApiController +{ + use ValidationTrait; + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = TaskFeedback::find($args['id']))) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + $task = Task::find($resource->task_id); + if (!Authority::canUpdateTaskFeedback($user = $this->getUser($request), $task)) { + throw new AuthorizationFailedException(); + } + $resource = $this->updateTaskFeedback($user, $resource, $json); + + return $this->getContentResponse($resource); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + + if (!self::arrayHas($json, 'data.id')) { + return 'Document must have an `id`.'; + } + } + + private function updateTaskFeedback(\User $user, TaskFeedback $resource, array $json): TaskFeedback + { + if (self::arrayHas($json, 'data.attributes.content')) { + $resource->content = self::arrayGet( + $json, + 'data.attributes.content' + ); + } + $resource->store(); + + if ($struct->range_type === 'courses') { + $data = [ + 'provider' => 'Studip\Activity\CoursewareProvider', + 'context' => 'course', + 'context_id' => $task->seminar_id, + 'content' => self::arrayGet($json, 'data.attributes.content', ''), + 'actor_type' => 'user', + 'actor_id' => $user->id, + 'verb' => 'answered', + 'object_id' => $task->structural_element_id, + 'object_type' => 'courseware', + 'mkdate' => time() + ]; + + $activity = Activity::create($data); + } + + return $resource; + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php new file mode 100644 index 00000000000..67cc27c9513 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php @@ -0,0 +1,212 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElement; +use Courseware\Task; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\StructuralElement as StructuralElementSchema; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use JsonApi\Schemas\StatusGroup as StatusGroupSchema; +use JsonApi\Schemas\User as UserSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Create a TaskGroup. + */ +class TaskGroupsCreate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + $structuralElement = $this->getTargetFromJson($json); + if (!Authority::canCreateTasks($user = $this->getUser($request), $structuralElement)) { + throw new AuthorizationFailedException(); + } + $taskGroup = $this->createTaskGroup($user, $json); + + return $this->getCreatedResponse($taskGroup); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * + * @param array $json + * @param mixed $data + * + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + if (TaskGroupSchema::TYPE !== self::arrayGet($json, 'data.type')) { + return 'Wrong `type` member of document´s `data`.'; + } + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + if (!self::arrayHas($json, 'data.attributes.title')) { + return 'Missing `title` attribute.'; + } + if (!self::arrayHas($json, 'data.attributes.submission-date')) { + return 'Missing `submission-date` attribute.'; + } + $submissionDate = self::arrayGet($json, 'data.attributes.submission-date'); + if (!self::isValidTimestamp($submissionDate)) { + return '`submission-date` is not an ISO 8601 timestamp.'; + } + + if (!self::arrayHas($json, 'data.relationships.target')) { + return 'Missing `target` relationship.'; + } + if (!self::arrayHas($json, 'data.relationships.task-template')) { + return 'Missing `task-template` relationship.'; + } + + if (!self::arrayHas($json, 'data.relationships.solvers')) { + return 'Missing `solvers` relationship.'; + } + + if (!$this->validateSolvers($json)) { + return 'Invalid `solvers` relationship.'; + } + if (!$this->getTargetFromJson($json)) { + return 'Invalid `target` relationship.'; + } + if (!$this->getTaskTemplateFromJson($json)) { + return 'Invalid `task-template` relationship.'; + } + } + + private function validateSolvers(array $json): bool + { + if (!self::arrayHas($json, 'data.relationships.solvers.data')) { + return false; + } + + $data = self::arrayGet($json, 'data.relationships.solvers.data'); + + if (!is_array($data) || !count($data)) { + return false; + } + + foreach ($data as $resourceIdentifier) { + if ( + !( + $this->validateResourceObject($resourceIdentifier, '', UserSchema::TYPE) || + $this->validateResourceObject($resourceIdentifier, '', StatusGroupSchema::TYPE) + ) + ) { + return false; + } + } + + return true; + } + + private function getSolversFromJson(array $json): iterable + { + if (!self::arrayHas($json, 'data.relationships.solvers.data')) { + return []; + } + + $solvers = []; + $mapping = [UserSchema::TYPE => \User::class, StatusGroupSchema::TYPE => \Statusgruppen::class]; + foreach (self::arrayGet($json, 'data.relationships.solvers.data') as $resourceIdentifier) { + $solvers[] = $mapping[$resourceIdentifier['type']]::find($resourceIdentifier['id']); + } + + return $solvers; + } + + private function getTargetFromJson(array $json): ?StructuralElement + { + if (!$this->validateResourceObject($json, 'data.relationships.target', StructuralElementSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.target.data.id'); + + return StructuralElement::find($resourceId); + } + + private function getTaskTemplateFromJson(array $json): ?StructuralElement + { + if (!$this->validateResourceObject($json, 'data.relationships.task-template', StructuralElementSchema::TYPE)) { + return null; + } + $resourceId = self::arrayGet($json, 'data.relationships.task-template.data.id'); + + return StructuralElement::find($resourceId); + } + + private function createTaskGroup(\User $lecturer, array $json): TaskGroup + { + $tasks = []; + + $solvers = $this->getSolversFromJson($json); + $taskTemplate = $this->getTaskTemplateFromJson($json); + $target = $this->getTargetFromJson($json); + + $solverMayAddBlocks = self::arrayGet($json, 'data.attributes.solver-may-add-blocks', ''); + $submissionDate = self::arrayGet($json, 'data.attributes.submission-date', ''); + $submissionDate = self::fromISO8601($submissionDate); + $title = self::arrayGet($json, 'data.attributes.title', ''); + + /** @var TaskGroup $taskGroup */ + $taskGroup = TaskGroup::create([ + 'seminar_id' => $target['range_id'], + 'lecturer_id' => $lecturer->getId(), + 'target_id' => $target->getId(), + 'task_template_id' => $taskTemplate->getId(), + 'solver_may_add_blocks' => $solverMayAddBlocks, + 'title' => $title, + ]); + + foreach ($solvers as $solver) { + $task = Task::build([ + 'task_group_id' => $taskGroup->getId(), + 'solver_id' => $solver->getId(), + 'solver_type' => $this->getSolverType($solver), + 'submission_date' => $submissionDate->getTimestamp(), + ]); + + // copy task template + $taskElement = $taskTemplate->copy($lecturer, $target); + $taskElement->purpose = 'task'; + $taskElement->store(); + + //update task with element id + $task['structural_element_id'] = $taskElement->id; + $task->store(); + } + + return $taskGroup; + } + + /** + * @param \User|\Statusgruppen $solver + */ + private function getSolverType($solver): string + { + $solverTypes = [\User::class => 'autor', \Statusgruppen::class => 'group']; + + return $solverTypes[get_class($solver)]; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php new file mode 100644 index 00000000000..c8ebb86e31b --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php @@ -0,0 +1,46 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays one TaskGroup. + */ +class TaskGroupsShow extends JsonApiController +{ + protected $allowedIncludePaths = [ + TaskGroupSchema::REL_COURSE, + TaskGroupSchema::REL_LECTURER, + TaskGroupSchema::REL_SOLVERS, + TaskGroupSchema::REL_TARGET, + TaskGroupSchema::REL_TASK_TEMPLATE, + TaskGroupSchema::REL_TASKS, + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\TaskGroup $resource */ + $resource = TaskGroup::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowTaskGroup($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksDelete.php b/lib/classes/JsonApi/Routes/Courseware/TasksDelete.php new file mode 100755 index 00000000000..45c78dbd551 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TasksDelete.php @@ -0,0 +1,39 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Task; +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 Task. + */ +class TasksDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\Task $resource */ + $resource = Task::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeleteTask($user = $this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + if ($feedback = $resource->getFeedback()) { + $feedback->delete(); + } + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php new file mode 100755 index 00000000000..f0b2ce9a53a --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php @@ -0,0 +1,106 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Task; +use Courseware\TaskGroup; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays all Tasks. + */ +class TasksIndex extends JsonApiController +{ + protected $allowedFilteringParameters = ['cid']; + + protected $allowedIncludePaths = [ + TaskSchema::REL_FEEDBACK, + TaskSchema::REL_SOLVER, + TaskSchema::REL_STRUCTURAL_ELEMENT, + TaskSchema::REL_TASK_GROUP, + TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER, + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param array $args + * + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + if ($error = $this->validateFilters()) { + throw new BadRequestException($error); + } + + $filtering = $this->getQueryParameters()->getFilteringParameters() ?: []; + $resources = []; + + if (empty($filtering)) { + if (!Authority::canIndexTasks($this->getUser($request))) { + throw new AuthorizationFailedException('Only root users may index all tasks without a `filter[cid]`.'); + } + + $resources = Task::findBySQL('1 ORDER BY mkdate', []); + } else { + $user = $this->getUser($request); + /** @var ?\Course $course */ + $course = \Course::find($filtering['cid']); + + if ($GLOBALS['perm']->have_studip_perm('tutor', $course->getId(), $user->getId())) { + $resources = $this->findTasksByCourse($course); + } else { + $resources = $this->findTasksByCourseMember($user, $course); + } + } + + return $this->getContentResponse($resources); + } + + private function validateFilters() + { + $filtering = $this->getQueryParameters()->getFilteringParameters() ?: []; + + // course + if (isset($filtering['cid'])) { + $course = \Course::find($filtering['cid']); + if (!$course) { + return 'Could not find a course matching this `filter[cid]`.'; + } + } + } + + private function findTasksByCourse(\Course $course): \SimpleCollection + { + $taskGroups = TaskGroup::findBySQL('seminar_id = ?', [$course->getId()]); + + $tasks = []; + foreach ($taskGroups as $taskGroup) { + $tasks[] = $taskGroup->tasks->getArrayCopy(); + } + $tasks = \SimpleORMapCollection::createFromArray(array_flatten($tasks), false)->orderBy('id asc'); + + return $tasks; + } + + private function findTasksByCourseMember(\User $user, \Course $course): \SimpleCollection + { + $groupIds = $course['statusgruppen'] + ->filter(function (\Statusgruppen $group) use ($user) { + return $group->isMember($user->getId()); + }) + ->pluck('id'); + + return $this->findTasksByCourse($course)->filter(function ($task) use ($user, $groupIds) { + return ('autor' === $task['solver_type'] && $task['solver_id'] === $user->getId()) || + ('group' === $task['solver_type'] && in_array($task['solver_id'], $groupIds)); + }); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php new file mode 100755 index 00000000000..619e7eab9e7 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php @@ -0,0 +1,46 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Task; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays one Task. + */ +class TasksShow extends JsonApiController +{ + protected $allowedIncludePaths = [ + TaskSchema::REL_FEEDBACK, + TaskSchema::REL_SOLVER, + TaskSchema::REL_STRUCTURAL_ELEMENT, + TaskSchema::REL_TASK_GROUP, + TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER, + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\Task $resource */ + $resource = Task::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowTask($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php new file mode 100755 index 00000000000..3728dba9a6b --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php @@ -0,0 +1,118 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Task; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\TimestampTrait; +use JsonApi\Routes\ValidationTrait; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Update one Task. + */ +class TasksUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param array $args + * @return Response + */ + public function __invoke(Request $request, Response $response, $args) + { + /** @var ?\Courseware\Task $resource */ + $resource = Task::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + if (!Authority::canUpdateTask($user = $this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource = $this->updateTask($user, $resource, $json); + + return $this->getContentResponse($resource); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + * @param array $json + * @param mixed $data + * @return string|void + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + + if (!self::arrayHas($json, 'data.id')) { + return 'Document must have an `id`.'; + } + + if (self::arrayHas($json, 'data.attributes.renewal-date')) { + $renewalDate = self::arrayGet($json, 'data.attributes.renewal-date'); + if (!self::isValidTimestamp($renewalDate)) { + return '`renewal-date` is not an ISO 8601 timestamp.'; + } + } + } + + private function updateTask(\User $user, Task $resource, array $json): Task + { + if (Authority::canDeleteTask($user, $resource)) { + if (self::arrayHas($json, 'data.attributes.renewal')) { + $newRenewalState = self::arrayGet($json, 'data.attributes.renewal'); + if ('declined' === $newRenewalState) { + $resource->renewal = $newRenewalState; + } + if ('granted' === $newRenewalState && self::arrayHas($json, 'data.attributes.renewal-date')) { + $renewalDate = self::arrayGet($json, 'data.attributes.renewal-date', ''); + $renewalDate = self::fromISO8601($renewalDate); + + $resource->renewal = $newRenewalState; + $resource->renewal_date = $renewalDate->getTimestamp(); + } + } + } else { + if (self::arrayHas($json, 'data.attributes.submitted')) { + $newSubmittedState = self::arrayGet($json, 'data.attributes.submitted'); + if ($this->canSubmit($resource, $newSubmittedState)) { + $resource->submitted = $newSubmittedState; + if ('pending' === $resource->renewal) { + $resource->renewal = ''; + } + } + } + if (self::arrayHas($json, 'data.attributes.renewal')) { + $newRenewalState = self::arrayGet($json, 'data.attributes.renewal'); + if ('pending' === $newRenewalState) { + $resource->renewal = $newRenewalState; + } + } + } + + $resource->store(); + + return $resource; + } + + private function canSubmit(Task $resource, string $newSubmittedState): bool + { + $now = time(); + if (1 === (int) $resource->submitted || !$newSubmittedState) { + return false; + } + if ('granted' === $resource->renewal) { + return $now <= $resource->renewal_date; + } else { + return $now <= $resource->submission_date; + } + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TemplatesCreate.php b/lib/classes/JsonApi/Routes/Courseware/TemplatesCreate.php new file mode 100755 index 00000000000..f23468c75ef --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TemplatesCreate.php @@ -0,0 +1,88 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Template; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\UnprocessableEntityException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Courseware\Task as TaskSchema; +use JsonApi\Schemas\Courseware\StructuralElement as StructuralElementSchema; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Create a Template. + */ +class TemplatesCreate extends JsonApiController +{ + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + if (!Authority::canCreateTemplate($this->getUser($request))) { + throw new AuthorizationFailedException(); + } + + $template = $this->createTemplate($json); + + return $this->getCreatedResponse($template); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + + if (self::arrayHas($json, 'data.id')) { + return 'New document must not have an `id`.'; + } + if (!self::arrayHas($json, 'data.name')) { + return 'Missing `name` value.'; + } + if (!self::arrayHas($json, 'data.purpose')) { + return 'Missing `purpose` attribute.'; + } + if (!self::arrayHas($json, 'data.structure')) { + return 'Missing `structure` attribute.'; + } + } + + private function createTemplate(array $json): Template + { + $get = function ($key, $default = '') use ($json) { + return self::arrayGet($json, $key, $default); + }; + + $template = Template::build([ + 'name' => $get('data.name'), + 'purpose' => $get('data.purpose'), + 'structure' => $this->cleanStructure($get('data.structure'), $get('data.name')), + ]); + $template->store(); + + return $template; + } + + private function cleanStructure($json, $name): string + { + $structural_element_uploaded = json_decode($json, true); + + $structural_element = [ + 'type' => $structural_element_uploaded['type'], + 'attributes' => ['title' => $name], + 'containers' => $structural_element_uploaded['containers'], + ]; + + return json_encode($structural_element); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TemplatesDelete.php b/lib/classes/JsonApi/Routes/Courseware/TemplatesDelete.php new file mode 100755 index 00000000000..406a3720b17 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TemplatesDelete.php @@ -0,0 +1,33 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Template; +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 Template. + */ +class TemplatesDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = Template::find($args['id']))) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeleteTemplate($user = $this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/TemplatesIndex.php b/lib/classes/JsonApi/Routes/Courseware/TemplatesIndex.php new file mode 100755 index 00000000000..1831bb7ef29 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TemplatesIndex.php @@ -0,0 +1,32 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Template; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays all Templates + */ +class TemplatesIndex extends JsonApiController +{ + + protected $allowedIncludePaths = []; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!Authority::canIndexTemplates($this->getUser($request))) { + throw new AuthorizationFailedException(); + } + + $resources = Template::findBySQL('1 ORDER BY mkdate', []); + + return $this->getContentResponse($resources); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/TemplatesShow.php b/lib/classes/JsonApi/Routes/Courseware/TemplatesShow.php new file mode 100755 index 00000000000..ef28e0b3e54 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TemplatesShow.php @@ -0,0 +1,34 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Template; +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 one Template. + */ +class TemplatesShow extends JsonApiController +{ + protected $allowedIncludePaths = []; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = Template::find($args['id']))) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowTemplate($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/TemplatesUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TemplatesUpdate.php new file mode 100755 index 00000000000..2ccb8a79809 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/TemplatesUpdate.php @@ -0,0 +1,80 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Template; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Errors\UnprocessableEntityException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Update one Template. + */ +class TemplatesUpdate extends JsonApiController +{ + use ValidationTrait; + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($resource = Template::find($args['id']))) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + if (!Authority::canUpdateTemplate($user = $this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource = $this->updateTemplate($resource, $json); + + return $this->getContentResponse($resource); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameters) + */ + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data')) { + return 'Missing `data` member at document´s top level.'; + } + + if (!self::arrayHas($json, 'data.id')) { + return 'Document must have an `id`.'; + } + + if (!self::arrayHas($json, 'data.name')) { + return 'Document must have an `name`.'; + } + + if (!self::arrayHas($json, 'data.purpose')) { + return 'Document must have an `purpose`.'; + } + } + + private function updateTemplate(Template $resource, array $json): Template + { + if (self::arrayHas($json, 'data.name')) { + $resource->name = self::arrayGet( + $json, + 'data.name' + ); + } + + if (self::arrayHas($json, 'data.purpose')) { + $resource->purpose = self::arrayGet( + $json, + 'data.purpose' + ); + } + + $resource->store(); + + return $resource; + } + +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/UsersBookmarkedStructuralElementsIndex.php b/lib/classes/JsonApi/Routes/Courseware/UsersBookmarkedStructuralElementsIndex.php new file mode 100755 index 00000000000..618dc2e38e2 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/UsersBookmarkedStructuralElementsIndex.php @@ -0,0 +1,55 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\Bookmark; +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 user's bookmarked structural elements. + */ +class UsersBookmarkedStructuralElementsIndex extends JsonApiController +{ + use CoursewareInstancesHelper; + + protected $allowedIncludePaths = [ + 'ancestors', + 'containers', + 'containers.blocks', + 'containers.blocks.edit-blocker', + 'containers.blocks.editor', + 'containers.blocks.owner', + 'containers.blocks.user-data-field', + 'containers.blocks.user-progress', + 'course', + 'editor', + 'owner', + 'parent', + ]; + + protected $allowedPagingParameters = ['offset', 'limit']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!($user = \User::find($args['id']))) { + throw new RecordNotFoundException(); + } + $actor = $this->getUser($request); + if (!Authority::canIndexBookmarksOfAUser($actor, $user)) { + throw new AuthorizationFailedException(); + } + + $resources = array_column(Bookmark::findUsersBookmarks($user), 'element'); + $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 042ee58eab2..de6e24759ea 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -54,8 +54,14 @@ class SchemaMap \Courseware\Container::class => Schemas\Courseware\Container::class, \Courseware\Instance::class => Schemas\Courseware\Instance::class, \Courseware\StructuralElement::class => Schemas\Courseware\StructuralElement::class, + \Courseware\StructuralElementComment::class => Schemas\Courseware\StructuralElementComment::class, + \Courseware\StructuralElementFeedback::class => Schemas\Courseware\StructuralElementFeedback::class, \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class, \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class, + \Courseware\Task::class => Schemas\Courseware\Task::class, + \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class, + \Courseware\TaskFeedback::class => Schemas\Courseware\TaskFeedback::class, + \Courseware\Template::class => Schemas\Courseware\Template::class, ]; } } diff --git a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php index 15c5ee2427d..49bd4cdc81d 100755 --- a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php +++ b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php @@ -22,6 +22,7 @@ class StructuralElement extends SchemaProvider const REL_OWNER = 'owner'; const REL_PARENT = 'parent'; const REL_USER = 'user'; + const REL_TASK = 'task'; /** * {@inheritdoc} @@ -79,11 +80,7 @@ class StructuralElement extends SchemaProvider $this->shouldInclude($context, self::REL_CONTAINERS) ); - $relationships = $this->addRangeRelationship( - $relationships, - $resource, - $context - ); + $relationships = $this->addRangeRelationship($relationships, $resource, $context); $relationships = $this->addOwnerRelationship( $relationships, @@ -127,6 +124,12 @@ class StructuralElement extends SchemaProvider $this->shouldInclude($context, self::REL_IMAGE) ); + $relationships = $this->addTaskRelationship( + $relationships, + $resource, + $this->shouldInclude($context, self::REL_TASK) + ); + return $relationships; } @@ -158,11 +161,9 @@ class StructuralElement extends SchemaProvider if ($includeData) { $user = $this->currentUser; - $relation[self::RELATIONSHIP_DATA] = $resource->children->filter( - function ($child) use ($user) { - return $child->canRead($user); - } - ); + $relation[self::RELATIONSHIP_DATA] = $resource->children->filter(function ($child) use ($user) { + return $child->canRead($user); + }); } $relationships[self::REL_CHILDREN] = $relation; @@ -241,7 +242,9 @@ class StructuralElement extends SchemaProvider $relation[self::RELATIONSHIP_LINKS] = [ Link::RELATED => $this->createLinkToUser($resource['edit_blocker_id']), ]; - $relation[self::RELATIONSHIP_DATA] = $includeData ? $resource->edit_blocker : new Identifier($resource['edit_blocker_id'], \JsonApi\Schemas\User::TYPE); + $relation[self::RELATIONSHIP_DATA] = $includeData + ? $resource->edit_blocker + : new Identifier($resource['edit_blocker_id'], \JsonApi\Schemas\User::TYPE); } else { $relation[self::RELATIONSHIP_DATA] = null; } @@ -257,7 +260,9 @@ class StructuralElement extends SchemaProvider $relation[self::RELATIONSHIP_LINKS] = [ Link::RELATED => $this->createLinkToUser($resource['editor_id']), ]; - $relation[self::RELATIONSHIP_DATA] = $includeData ? $resource->editor : new Identifier($resource['editor_id'], \JsonApi\Schemas\User::TYPE); + $relation[self::RELATIONSHIP_DATA] = $includeData + ? $resource->editor + : new Identifier($resource['editor_id'], \JsonApi\Schemas\User::TYPE); } else { $relation[self::RELATIONSHIP_DATA] = null; } @@ -273,7 +278,9 @@ class StructuralElement extends SchemaProvider $relation[self::RELATIONSHIP_LINKS] = [ Link::RELATED => $this->createLinkToUser($resource['owner_id']), ]; - $relation[self::RELATIONSHIP_DATA] = $includeData ? $resource->owner : new Identifier($resource['owner_id'], \JsonApi\Schemas\User::TYPE); + $relation[self::RELATIONSHIP_DATA] = $includeData + ? $resource->owner + : new Identifier($resource['owner_id'], \JsonApi\Schemas\User::TYPE); } else { $relation[self::RELATIONSHIP_DATA] = null; } @@ -290,7 +297,9 @@ class StructuralElement extends SchemaProvider $relation[self::RELATIONSHIP_LINKS] = [ Link::RELATED => $this->createLinkToStructuralElement($resource['parent_id']), ]; - $relation[self::RELATIONSHIP_DATA] = $includeData ? $resource->parent : new Identifier($resource['parent_id'], self::TYPE); + $relation[self::RELATIONSHIP_DATA] = $includeData + ? $resource->parent + : new Identifier($resource['parent_id'], self::TYPE); } else { $relation[self::RELATIONSHIP_DATA] = null; } @@ -307,7 +316,9 @@ class StructuralElement extends SchemaProvider self::RELATIONSHIP_LINKS => [ Link::RELATED => $this->createLinkToCourse($resource['range_id']), ], - self::RELATIONSHIP_DATA => $includeData ? $resource->course : new Identifier($resource['range_id'], \JsonApi\Schemas\Course::TYPE), + self::RELATIONSHIP_DATA => $includeData + ? $resource->course + : new Identifier($resource['range_id'], \JsonApi\Schemas\Course::TYPE), ]; } elseif ($resource['range_type'] === 'user') { $includeData = $this->shouldInclude($context, self::REL_USER); @@ -315,13 +326,34 @@ class StructuralElement extends SchemaProvider self::RELATIONSHIP_LINKS => [ Link::RELATED => $this->createLinkToUser($resource['range_id']), ], - self::RELATIONSHIP_DATA => $includeData ? $resource->user : new Identifier($resource['range_id'], \JsonApi\Schemas\User::TYPE), + self::RELATIONSHIP_DATA => $includeData + ? $resource->user + : new Identifier($resource['range_id'], \JsonApi\Schemas\User::TYPE), ]; } return $relationships; } + private function addTaskRelationship( + array $relationships, + $resource, + bool $shouldInclude + ): array { + $relationships[self::REL_TASK] = $resource->task + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->task), + ], + self::RELATIONSHIP_DATA => $resource->task, + ] + : [ + self::RELATIONSHIP_DATA => null, + ]; + + return $relationships; + } + private static $memo = []; private function createLinkToCourse($rangeId) diff --git a/lib/classes/JsonApi/Schemas/Courseware/StructuralElementComment.php b/lib/classes/JsonApi/Schemas/Courseware/StructuralElementComment.php new file mode 100755 index 00000000000..7fe75ff4f71 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/StructuralElementComment.php @@ -0,0 +1,62 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class StructuralElementComment extends SchemaProvider +{ + const TYPE = 'courseware-structural-element-comments'; + + const REL_USER = 'user'; + const REL_STRUCTURAL_ELEMENT = 'structural-element'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'comment' => (string) $resource['comment'], + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $isPrimary = $context->getPosition()->getLevel() === 0; + $includeList = $context->getIncludePaths(); + + $relationships = []; + + $relationships[self::REL_STRUCTURAL_ELEMENT] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->structural_element), + ], + self::RELATIONSHIP_DATA => $resource->structural_element, + ]; + + $relationships[self::REL_USER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->user), + ], + self::RELATIONSHIP_DATA => $resource->user, + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/StructuralElementFeedback.php b/lib/classes/JsonApi/Schemas/Courseware/StructuralElementFeedback.php new file mode 100755 index 00000000000..c7a9cc80553 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/StructuralElementFeedback.php @@ -0,0 +1,62 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class StructuralElementFeedback extends SchemaProvider +{ + const TYPE = 'courseware-structural-element-feedback'; + + const REL_USER = 'user'; + const REL_STRUCTURAL_ELEMENT = 'structural-element'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'feedback' => (string) $resource['feedback'], + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $isPrimary = $context->getPosition()->getLevel() === 0; + $includeList = $context->getIncludePaths(); + + $relationships = []; + + $relationships[self::REL_STRUCTURAL_ELEMENT] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->structural_element), + ], + self::RELATIONSHIP_DATA => $resource->structural_element, + ]; + + $relationships[self::REL_USER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->user), + ], + self::RELATIONSHIP_DATA => $resource->user, + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/Task.php b/lib/classes/JsonApi/Schemas/Courseware/Task.php new file mode 100755 index 00000000000..32d4a187b4d --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/Task.php @@ -0,0 +1,85 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class Task extends SchemaProvider +{ + const TYPE = 'courseware-tasks'; + + const REL_FEEDBACK = 'task-feedback'; + const REL_SOLVER = 'solver'; + const REL_STRUCTURAL_ELEMENT = 'structural-element'; + const REL_TASK_GROUP = 'task-group'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'progress' => (float) $resource->getTaskProgress(), + 'submission-date' => date('c', $resource['submission_date']), + 'submitted' => (bool) $resource['submitted'], + 'renewal' => empty($resource['renewal']) ? null : (string) $resource['renewal'], + 'renewal-date' => $resource['renewal_date'] ? date('c', $resource['renewal_date']) : null, + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships[self::REL_FEEDBACK] = $resource->getFeedback() + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->getFeedback()), + ], + self::RELATIONSHIP_DATA => $resource->getFeedback(), + ] + : [self::RELATIONSHIP_DATA => null]; + + $relationships[self::REL_SOLVER] = $resource['solver_id'] + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->getSolver()), + ], + self::RELATIONSHIP_DATA => $resource->getSolver(), + ] + : [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]; + + $relationships[self::REL_TASK_GROUP] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource['task_group']), + ], + self::RELATIONSHIP_DATA => $resource['task_group'], + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskFeedback.php b/lib/classes/JsonApi/Schemas/Courseware/TaskFeedback.php new file mode 100755 index 00000000000..8d800c88a89 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/TaskFeedback.php @@ -0,0 +1,61 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class TaskFeedback extends SchemaProvider +{ + const TYPE = 'courseware-task-feedback'; + + const REL_TASK = 'task'; + const REL_LECTURER = 'lecturer'; + + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'content' => (string) $resource['content'], + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships[self::REL_LECTURER] = $resource['lecturer_id'] + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->lecturer), + ], + self::RELATIONSHIP_DATA => $resource->lecturer, + ] + : [self::RELATIONSHIP_DATA => $resource->lecturer]; + + $relationships[self::REL_TASK] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_TASK), + ], + ]; + + return $relationships; + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php new file mode 100755 index 00000000000..12dbc6c5855 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php @@ -0,0 +1,104 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use Courseware\StructuralElement; +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Identifier; +use Neomerx\JsonApi\Schema\Link; + +class TaskGroup extends SchemaProvider +{ + const TYPE = 'courseware-task-groups'; + + const REL_COURSE = 'course'; + const REL_LECTURER = 'lecturer'; + const REL_SOLVERS = 'solvers'; + const REL_TARGET = 'target'; + const REL_TASK_TEMPLATE = 'task-template'; + const REL_TASKS = 'tasks'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'solver-may-add-blocks' => (bool) $resource['solver_may_add_blocks'], + 'title' => (string) $resource->title, + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships[self::REL_COURSE] = $resource['seminar_id'] + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->course), + ], + self::RELATIONSHIP_DATA => $resource->course, + ] + : [self::RELATIONSHIP_DATA => null]; + + $relationships[self::REL_LECTURER] = $resource['lecturer_id'] + ? [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($resource->lecturer), + ], + self::RELATIONSHIP_DATA => $resource->lecturer, + ] + : [self::RELATIONSHIP_DATA => null]; + + $relationships[self::REL_SOLVERS] = [ + self::RELATIONSHIP_DATA => $resource->getSolvers(), + ]; + + $target = StructuralElement::build(['id' => $resource['target_id']]); + $relationships[self::REL_TARGET] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($target), + ], + self::RELATIONSHIP_DATA => $this->shouldInclude($context, self::REL_TARGET) ? $resource['target'] : $target, + ]; + + $taskTemplate = StructuralElement::build(['id' => $resource['task_template_id']]); + $relationships[self::REL_TASK_TEMPLATE] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($taskTemplate), + ], + self::RELATIONSHIP_DATA => $this->shouldInclude($context, self::REL_TASK_TEMPLATE) + ? $resource['task_template'] + : $taskTemplate, + ]; + + $relationships[self::REL_TASKS] = [ + self::RELATIONSHIP_DATA => $this->shouldInclude($context, self::REL_TASKS) + ? $resource['tasks'] + : \DBManager::get()->fetchFirst( + 'SELECT id FROM cw_tasks WHERE task_group_id = ?', + [$resource->getId()], + function ($id) { + return new Identifier($id, Task::TYPE); + } + ), + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/Courseware/Template.php b/lib/classes/JsonApi/Schemas/Courseware/Template.php new file mode 100755 index 00000000000..4c0d0cecd63 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/Template.php @@ -0,0 +1,44 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class Template extends SchemaProvider +{ + const TYPE = 'courseware-templates'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'name' => (string) $resource['name'], + 'purpose' => (string) $resource['purpose'], + 'structure' => (string) $resource['structure'], + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + return $relationships; + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Schemas/User.php b/lib/classes/JsonApi/Schemas/User.php index 657f8f9c105..14787ce6d8e 100644 --- a/lib/classes/JsonApi/Schemas/User.php +++ b/lib/classes/JsonApi/Schemas/User.php @@ -16,6 +16,7 @@ class User extends SchemaProvider const REL_CONTACTS = 'contacts'; const REL_COURSES = 'courses'; const REL_COURSE_MEMBERSHIPS = 'course-memberships'; + const REL_COURSEWARE_BOOKMARKS = 'courseware-bookmarks'; const REL_EVENTS = 'events'; const REL_FILES = 'file-refs'; const REL_FOLDERS = 'folders'; @@ -165,6 +166,7 @@ class User extends SchemaProvider $relationships = $this->getNewsRelationship($relationships, $user, $this->shouldInclude($context, self::REL_NEWS)); $relationships = $this->getOutboxRelationship($relationships, $user, $this->shouldInclude($context, self::REL_OUTBOX)); $relationships = $this->getScheduleRelationship($relationships, $user, $this->shouldInclude($context, self::REL_SCHEDULE)); + $relationships = $this->getCoursewareBookmarksRelationship($relationships, $user, $this->shouldInclude($context, self::REL_COURSEWARE_BOOKMARKS)); } return $relationships; @@ -251,6 +253,22 @@ class User extends SchemaProvider self::RELATIONSHIP_LINKS => [ Link::RELATED => $this->getRelationshipRelatedLink($user, self::REL_COURSE_MEMBERSHIPS), ], + self::RELATIONSHIP_DATA => $resource->course_memberships, + ]; + + return $relationships; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + private function getCoursewareBookmarksRelationship(array $relationships, \User $user, $includeData) + { + $relationships[self::REL_COURSEWARE_BOOKMARKS] = [ + self::RELATIONSHIP_LINKS_SELF => true, + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($user, self::REL_COURSEWARE_BOOKMARKS), + ], ]; return $relationships; diff --git a/lib/models/Courseware/Block.php b/lib/models/Courseware/Block.php index 48009fe8697..05d5d45c1b1 100755 --- a/lib/models/Courseware/Block.php +++ b/lib/models/Courseware/Block.php @@ -207,4 +207,19 @@ class Block extends \SimpleORMap return 'error'; } } + + public function getStructuralElement(): ?StructuralElement + { + $sql = 'SELECT se.* + FROM cw_blocks b + JOIN cw_containers c ON c.id = b.container_id + JOIN cw_structural_elements se ON se.id = c.structural_element_id + WHERE b.id = ?'; + $structuralElement = \DBManager::get()->fetchOne($sql, [$this->getId()]); + if (!count($structuralElement)) { + return null; + } + + return StructuralElement::build($structuralElement, false); + } } diff --git a/lib/models/Courseware/BlockComment.php b/lib/models/Courseware/BlockComment.php index c985e8978f3..16f3a73293d 100755 --- a/lib/models/Courseware/BlockComment.php +++ b/lib/models/Courseware/BlockComment.php @@ -41,4 +41,20 @@ class BlockComment extends \SimpleORMap parent::configure($config); } + + public function getStructuralElement(): ?StructuralElement + { + $sql = 'SELECT se.* + FROM cw_block_comments bc + JOIN cw_blocks b ON b.id = bc.block_id + JOIN cw_containers c ON c.id = b.container_id + JOIN cw_structural_elements se ON se.id = c.structural_element_id + WHERE bc.id = ?'; + $structuralElement = \DBManager::get()->fetchOne($sql, [$this->getId()]); + if (!count($structuralElement)) { + return null; + } + + return StructuralElement::build($structuralElement, false); + } } diff --git a/lib/models/Courseware/BlockFeedback.php b/lib/models/Courseware/BlockFeedback.php index 08e5c6f7d06..422577097a1 100755 --- a/lib/models/Courseware/BlockFeedback.php +++ b/lib/models/Courseware/BlockFeedback.php @@ -39,4 +39,20 @@ class BlockFeedback extends \SimpleORMap parent::configure($config); } + + public function getStructuralElement(): ?StructuralElement + { + $sql = 'SELECT se.* + FROM cw_block_feedbacks bf + JOIN cw_blocks b ON b.id = bf.block_id + JOIN cw_containers c ON c.id = b.container_id + JOIN cw_structural_elements se ON se.id = c.structural_element_id + WHERE bf.id = ?'; + $structuralElement = \DBManager::get()->fetchOne($sql, [$this->getId()]); + if (!count($structuralElement)) { + return null; + } + + return StructuralElement::build($structuralElement, false); + } } diff --git a/lib/models/Courseware/BlockTypes/BlockType.php b/lib/models/Courseware/BlockTypes/BlockType.php index 0d994344d12..98e24aef69b 100755 --- a/lib/models/Courseware/BlockTypes/BlockType.php +++ b/lib/models/Courseware/BlockTypes/BlockType.php @@ -310,7 +310,7 @@ abstract class BlockType $user ); - return isset($copiedFile) ? $copiedFile->id : ''; + return isset($copiedFile->id) ? $copiedFile->id : ''; } return ''; @@ -373,4 +373,17 @@ abstract class BlockType return $destinationFolder; } + + public function pdfExport() + { + $html = '<h5>' . sprintf(_('Block-Typ: %s'), $this->getTitle()) . '</h5>'; + $html .= '<h6>' . _('Block-Daten') . ': ' . '</h6>'; + foreach($this->getPayload() as $key => $value) { + if ($value !== '') { + $html .= '<h6>' . $key . ' => ' . $value . '</h6>'; + } + } + + return $html; + } } diff --git a/lib/models/Courseware/BlockTypes/Code.php b/lib/models/Courseware/BlockTypes/Code.php index 2ee634255a3..99888d4f380 100755 --- a/lib/models/Courseware/BlockTypes/Code.php +++ b/lib/models/Courseware/BlockTypes/Code.php @@ -58,4 +58,12 @@ class Code extends BlockType { return []; } + + public function pdfExport() + { + $html = '<h5>' . sprintf(_('Block-Typ: %s'), $this->getTitle()) . '</h5>'; + $html .= '<pre>' . htmlspecialchars($this->getPayload()['content']) . '</pre>'; + + return $html; + } } diff --git a/lib/models/Courseware/BlockTypes/Confirm.php b/lib/models/Courseware/BlockTypes/Confirm.php index 7204e6bf8c8..92c61d4d467 100755 --- a/lib/models/Courseware/BlockTypes/Confirm.php +++ b/lib/models/Courseware/BlockTypes/Confirm.php @@ -57,4 +57,12 @@ class Confirm extends BlockType { return []; } + + public function pdfExport() + { + $html = '<h5>' . sprintf(_('Block-Typ: %s'), $this->getTitle()) . '</h5>'; + $html .= '<p>' . htmlspecialchars($this->getPayload()['text']) . '</p>'; + + return $html; + } } diff --git a/lib/models/Courseware/BlockTypes/Date.php b/lib/models/Courseware/BlockTypes/Date.php index df37590b315..66075f32fcd 100755 --- a/lib/models/Courseware/BlockTypes/Date.php +++ b/lib/models/Courseware/BlockTypes/Date.php @@ -58,4 +58,12 @@ class Date extends BlockType { return []; } + + public function pdfExport() + { + $html = '<h5>' . sprintf(_('Block-Typ: %s'), $this->getTitle()) . '</h5>'; + $html .= '<p>' . date('d.m.Y h:i', (int) $this->getPayload()['timestamp'] / 1000) . '</p>'; + + return $html; + } } diff --git a/lib/models/Courseware/BlockTypes/Headline.php b/lib/models/Courseware/BlockTypes/Headline.php index 855e2a94a54..a3add744f57 100755 --- a/lib/models/Courseware/BlockTypes/Headline.php +++ b/lib/models/Courseware/BlockTypes/Headline.php @@ -106,4 +106,13 @@ class Headline extends BlockType { return []; } + + public function pdfExport() + { + $html = '<h5>' . sprintf(_('Block-Typ: %s'), $this->getTitle()) . '</h5>'; + $html .= '<h5>' . htmlspecialchars($this->getPayload()['title']) . '</h5>'; + $html .= '<h6>' . htmlspecialchars($this->getPayload()['subtitle']) . '</h6>'; + + return $html; + } } diff --git a/lib/models/Courseware/BlockTypes/KeyPoint.php b/lib/models/Courseware/BlockTypes/KeyPoint.php index fae16d3b9a6..90f4852770a 100755 --- a/lib/models/Courseware/BlockTypes/KeyPoint.php +++ b/lib/models/Courseware/BlockTypes/KeyPoint.php @@ -59,4 +59,12 @@ class KeyPoint extends BlockType { return []; } + + public function pdfExport() + { + $html = '<h5>' . sprintf(_('Block-Typ: %s'), $this->getTitle()) . '</h5>'; + $html .= '<p>' . htmlspecialchars($this->getPayload()['text']) . '</p>'; + + return $html; + } } diff --git a/lib/models/Courseware/BlockTypes/Link.php b/lib/models/Courseware/BlockTypes/Link.php index 7b93aeb7166..1e804b74580 100755 --- a/lib/models/Courseware/BlockTypes/Link.php +++ b/lib/models/Courseware/BlockTypes/Link.php @@ -60,4 +60,13 @@ class Link extends BlockType { return []; } + + public function pdfExport() + { + $html = '<h5>' . sprintf(_('Block-Typ: %s'), $this->getTitle()) . '</h5>'; + $html .= '<p>' . htmlspecialchars($this->getPayload()['title']) . '</p>'; + $html .= '<p>' . htmlspecialchars($this->getPayload()['url']) . '</p>'; + + return $html; + } } diff --git a/lib/models/Courseware/BlockTypes/Text.php b/lib/models/Courseware/BlockTypes/Text.php index c4fe67c68db..3857c02911e 100755 --- a/lib/models/Courseware/BlockTypes/Text.php +++ b/lib/models/Courseware/BlockTypes/Text.php @@ -165,4 +165,12 @@ class Text extends BlockType return array(); }); } + + public function pdfExport() + { + $html = '<h5>' . sprintf(_('Block-Typ: %s'), $this->getTitle()) . '</h5>'; + $html .= $this->getPayload()['text']; + + return $html; + } } diff --git a/lib/models/Courseware/BlockTypes/Typewriter.php b/lib/models/Courseware/BlockTypes/Typewriter.php index 583e64e7a1e..064065bd613 100755 --- a/lib/models/Courseware/BlockTypes/Typewriter.php +++ b/lib/models/Courseware/BlockTypes/Typewriter.php @@ -60,4 +60,12 @@ class Typewriter extends BlockType { return []; } + + public function pdfExport() + { + $html = '<h5>' . sprintf(_('Block-Typ: %s'), $this->getTitle()) . '</h5>'; + $html .= '<p>' . htmlspecialchars($this->getPayload()['text']) . '</p>'; + + return $html; + } } diff --git a/lib/models/Courseware/Bookmark.php b/lib/models/Courseware/Bookmark.php index d6e5910521d..96f919dab1b 100755 --- a/lib/models/Courseware/Bookmark.php +++ b/lib/models/Courseware/Bookmark.php @@ -54,12 +54,12 @@ class Bookmark extends \SimpleORMap /** * Returns all bookmarks of a user. * - * @param string $userId the user's ID for whom to search for bookmarks + * @param \User $user the user for whom to search for bookmarks * * @return Bookmark[] the list of bookmarks */ - public function findUsersBookmarks(string $userId): array + public function findUsersBookmarks($user): array { - return self::findBySQL('user_id = ?', [$userId]); + return self::findBySQL('user_id = ? ORDER BY chdate', [$user->id]); } } diff --git a/lib/models/Courseware/ContainerTypes/AccordionContainer.php b/lib/models/Courseware/ContainerTypes/AccordionContainer.php index 832520401aa..510a388cdbb 100755 --- a/lib/models/Courseware/ContainerTypes/AccordionContainer.php +++ b/lib/models/Courseware/ContainerTypes/AccordionContainer.php @@ -56,4 +56,28 @@ class AccordionContainer extends ContainerType return Schema::fromJsonString(file_get_contents($schemaFile)); } + + public function pdfExport() + { + $html = '<h3>' . sprintf(_('Container-Typ: %s'), $this->getTitle()) . '</h3>'; + + $payload = $this->getPayload(); + + $sections = $payload['sections']; + foreach ($sections as $section) { + $block_ids = $section['blocks']; + $html .= '<h4>' . $section['name'] . '</h4>'; + foreach ($block_ids as $block_id) { + $block = $this->container->blocks->find($block_id); + if ($block) { + $html .= $block->type->PdfExport(); + } + else { + $html .= '<p>' . _('Block konnte nicht gefunden werden') . '</p>'; + } + } + } + + return $html; + } } diff --git a/lib/models/Courseware/ContainerTypes/ContainerType.php b/lib/models/Courseware/ContainerTypes/ContainerType.php index 34030775887..a2552d415af 100755 --- a/lib/models/Courseware/ContainerTypes/ContainerType.php +++ b/lib/models/Courseware/ContainerTypes/ContainerType.php @@ -244,4 +244,15 @@ abstract class ContainerType return _('unbekannter Courseware-Container'); } } + + public function pdfExport() + { + $html = '<h3>' . sprintf(_('Container-Typ: %s'), $this->getTitle()) . '</h3>'; + + foreach ($this->container->blocks as $block) { + $html .= $block->type->PdfExport(); + } + + return $html; + } } diff --git a/lib/models/Courseware/ContainerTypes/ListContainer.php b/lib/models/Courseware/ContainerTypes/ListContainer.php index 4918271a40e..d8e283c24f5 100755 --- a/lib/models/Courseware/ContainerTypes/ListContainer.php +++ b/lib/models/Courseware/ContainerTypes/ListContainer.php @@ -56,4 +56,24 @@ class ListContainer extends ContainerType return Schema::fromJsonString(file_get_contents($schemaFile)); } + + public function pdfExport() + { + $html = '<h3>' . sprintf(_('Container-Typ: %s'), $this->getTitle()) . '</h3>'; + + $payload = $this->getPayload(); + $block_ids = $payload['sections'][0]['blocks']; + + foreach ($block_ids as $block_id) { + $block = $this->container->blocks->find($block_id); + if ($block) { + $html .= $block->type->PdfExport(); + } + else { + $html .= '<p>' . _('Block konnte nicht gefunden werden') . '</p>'; + } + } + + return $html; + } } diff --git a/lib/models/Courseware/ContainerTypes/TabsContainer.php b/lib/models/Courseware/ContainerTypes/TabsContainer.php index b884bbb67ee..72a15f464a8 100755 --- a/lib/models/Courseware/ContainerTypes/TabsContainer.php +++ b/lib/models/Courseware/ContainerTypes/TabsContainer.php @@ -57,4 +57,28 @@ class TabsContainer extends ContainerType return Schema::fromJsonString(file_get_contents($schemaFile)); } + + public function pdfExport() + { + $html = '<h3>' . sprintf(_('Container-Typ: %s'), $this->getTitle()) . '</h3>'; + + $payload = $this->getPayload(); + + $sections = $payload['sections']; + foreach ($sections as $section) { + $block_ids = $section['blocks']; + $html .= '<h4>' . $section['name'] . '</h4>'; + foreach ($block_ids as $block_id) { + $block = $this->container->blocks->find($block_id); + if ($block) { + $html .= $block->type->PdfExport(); + } + else { + $html .= '<p>' . _('Block konnte nicht gefunden werden') . '</p>'; + } + } + } + + return $html; + } } diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index 44451504a56..54e28805414 100755 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -44,6 +44,9 @@ use User; * @property \User $editor belongs_to User * @property ?\User $edit_blocker belongs_to User * @property ?\FileRef $image has_one FileRef + * @property ?\Courseware\Task $task has_one Courseware\Task + * @property \SimpleORMapCollection $comments has_many Courseware\StructuralElementComment + * @property \SimpleORMapCollection $feedback has_many Courseware\StructuralElementFeedback * * @SuppressWarnings(PHPMD.TooManyPublicMethods) * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) @@ -76,6 +79,12 @@ class StructuralElement extends \SimpleORMap 'order_by' => 'ORDER BY position', ]; + $config['has_one']['task'] = [ + 'class_name' => Task::class, + 'assoc_foreign_key' => 'structural_element_id', + 'on_delete' => 'delete', + ]; + $config['belongs_to']['parent'] = [ 'class_name' => StructuralElement::class, 'foreign_key' => 'parent_id', @@ -97,14 +106,17 @@ class StructuralElement extends \SimpleORMap 'class_name' => User::class, 'foreign_key' => 'owner_id', ]; + $config['belongs_to']['editor'] = [ 'class_name' => User::class, 'foreign_key' => 'editor_id', ]; + $config['belongs_to']['edit_blocker'] = [ 'class_name' => User::class, 'foreign_key' => 'edit_blocker_id', ]; + $config['has_one']['image'] = [ 'class_name' => \FileRef::class, 'foreign_key' => 'image_id', @@ -112,6 +124,22 @@ class StructuralElement extends \SimpleORMap 'on_store' => 'store', ]; + $config['has_many']['comments'] = [ + 'class_name' => StructuralElementComment::class, + 'assoc_foreign_key' => 'structural_element_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY chdate', + ]; + + $config['has_many']['feedback'] = [ + 'class_name' => StructuralElementFeedback::class, + 'assoc_foreign_key' => 'structural_element_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY chdate', + ]; + parent::configure($config); } @@ -169,6 +197,14 @@ class StructuralElement extends \SimpleORMap return null === $this->parent_id; } + /** + * @return bool true, if this object purpose is task, false otherwise + */ + public function isTask(): bool + { + return $this->purpose === 'task'; + } + /** * @param User $user the user to validate * @@ -187,12 +223,25 @@ class StructuralElement extends \SimpleORMap return $this->range_id === $user->id; case 'course': - $haveStudipPerm = $GLOBALS['perm']->have_studip_perm( - \CourseConfig::get($this->range_id)->COURSEWARE_EDITING_PERMISSION, - $this->range_id, - $user->id - ); - if ($haveStudipPerm) { + $hasEditingPermission = $this->hasEditingPermission($user); + if ($this->isTask()) { + // TODO: Was tun wir, wenn dieses Strukturelement purpose=task aber keinen Task hat? + if (!$this->task) { + return false; + } + + if ($hasEditingPermission) { + return false; + } + + if ($this->task->isSubmitted()) { + return false; + } + + return $this->task->userIsASolver($user); + } + + if ($hasEditingPermission) { return true; } @@ -259,6 +308,19 @@ class StructuralElement extends \SimpleORMap return false; } + if ($this->isTask()) { + // TODO: Was tun wir, wenn dieses Strukturelement purpose=task aber keinen Task hat? + if (!$this->task) { + return false; + } + + if ($this->task->isSubmitted() && $this->hasEditingPermission($user)) { + return true; + } + + return $this->task->userIsASolver($user); + } + if ($this->canEdit($user)) { return true; } @@ -274,6 +336,19 @@ class StructuralElement extends \SimpleORMap } } + /** + * @param \User|\Seminar_User $user + */ + public function hasEditingPermission($user): bool + { + return $GLOBALS['perm']->have_perm('root', $user->id) || + $GLOBALS['perm']->have_studip_perm( + \CourseConfig::get($this->range_id)->COURSEWARE_EDITING_PERMISSION, + $this->range_id, + $user->id + ); + } + private function hasReadApproval($user): bool { if (!count($this->read_approval)) { @@ -410,10 +485,7 @@ class StructuralElement extends \SimpleORMap foreach ($this->containers as $container) { foreach ($container->blocks as $block) { /** @var ?UserProgress $progress */ - $progress = UserProgress::findOneBySQL('user_id = ? and block_id = ?', [ - $user->id, - $block->id, - ]); + $progress = UserProgress::findOneBySQL('user_id = ? and block_id = ?', [$user->id, $block->id]); if (!$progress || $progress->grade != 1) { return false; @@ -438,7 +510,11 @@ class StructuralElement extends \SimpleORMap if ('all' == $purpose) { return self::findBySQL('range_id = ? AND parent_id = ? ORDER BY position ASC', [$userId, $root->id]); } else { - return self::findBySQL('range_id = ? AND parent_id = ? AND purpose = ? ORDER BY position ASC', [$userId, $root->id, $purpose]); + return self::findBySQL('range_id = ? AND parent_id = ? AND purpose = ? ORDER BY position ASC', [ + $userId, + $root->id, + $purpose, + ]); } } @@ -563,7 +639,7 @@ class StructuralElement extends \SimpleORMap SQL; $params = [$range->getRangeId(), $range->getRangeType(), $user->id]; - return \DBManager::get()->fetchAll($sql, $params, StructuralElement::class.'::buildExisting'); + return \DBManager::get()->fetchAll($sql, $params, StructuralElement::class . '::buildExisting'); } /** @@ -638,4 +714,64 @@ SQL; $child->copy($user, $newElement); } } + + public function pdfExport($user) + { + $doc = new \ExportPDF('P', 'mm', 'A4', true, 'UTF-8', false); + $doc->setHeaderTitle(_('Courseware')); + if ($this->course) { + $doc->setHeaderTitle(sprintf(_('Courseware aus %s'), $this->course->name)); + } + if ($this->user) { + $doc->setHeaderTitle(sprintf(_('Courseware von %s'), $this->user->getFullname())); + } + + $doc->addPage(); + + if (!self::canRead($user)) { + $doc->addContent(_('Diese Seite steht Ihnen nicht zur Verfügung!')); + + return $doc; + } + + $doc->writeHTML($this->getElementPdfExport()); + + return $doc; + } + + private function getElementPdfExport(string $parent_name = '', bool $with_children = false) + { + if ($parent_name !== '') { + $parent_name .= ' / '; + } + $html = '<h1>' . $parent_name . $this->title . '</h1>'; + $html .= $this->getContainerPdfExport(); + if ($with_children) { + $html .= $this->getChildrenPdfExport($parent_name); + } + + return $html; + } + + private function getChildrenPdfExport(string $parent_name) + { + $children = self::findBySQL('parent_id = ?', [$this->id]); + $html = ''; + foreach ($children as $child) { + $html .= $child->getElementPdfExport($parent_name . $this->title); + } + + return $html; + } + + private function getContainerPdfExport() + { + $containers = \Courseware\Container::findBySQL('structural_element_id = ?', [$this->id]); + + foreach ($containers as $container) { + $html .= $container->type->pdfExport(); + } + + return $html; + } } diff --git a/lib/models/Courseware/StructuralElementComment.php b/lib/models/Courseware/StructuralElementComment.php new file mode 100755 index 00000000000..e5fbc818a9c --- /dev/null +++ b/lib/models/Courseware/StructuralElementComment.php @@ -0,0 +1,42 @@ +<?php + +namespace Courseware; + +use User; + +/** + * Courseware's comments on structural elements. + * + * @author Ron Lucke <lucke@elan-ev.de> + * @license GPL2 or any later version + * + * @since Stud.IP 5.1 + * + * @property int $id database column + * @property int $structural_element_id database column + * @property string $user_id database column + * @property string $comment database column + * @property int $mkdate database column + * @property int $chdate database column + * @property \User $user belongs_to User + * @property \Courseware\StructuralElement $structural_element belongs_to Courseware\StructuralElement + */ +class StructuralElementComment extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_structural_element_comments'; + + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'user_id', + ]; + + $config['belongs_to']['structural_element'] = [ + 'class_name' => StructuralElement::class, + 'foreign_key' => 'structural_element_id', + ]; + + parent::configure($config); + } +} diff --git a/lib/models/Courseware/StructuralElementFeedback.php b/lib/models/Courseware/StructuralElementFeedback.php new file mode 100755 index 00000000000..a2e3b8b97ab --- /dev/null +++ b/lib/models/Courseware/StructuralElementFeedback.php @@ -0,0 +1,42 @@ +<?php + +namespace Courseware; + +use User; + +/** + * Courseware's feedback on structural elements. + * + * @author Ron Lucke <lucke@elan-ev.de> + * @license GPL2 or any later version + * + * @since Stud.IP 5.1 + * + * @property int $id database column + * @property int $structural_element_id database column + * @property string $user_id database column + * @property string $feedback database column + * @property int $mkdate database column + * @property int $chdate database column + * @property \User $user belongs_to User + * @property \Courseware\StructuralElement $structural_element belongs_to Courseware\StructuralElement + */ +class StructuralElementFeedback extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_structural_element_feedbacks'; + + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'user_id', + ]; + + $config['belongs_to']['structural_element'] = [ + 'class_name' => StructuralElement::class, + 'foreign_key' => 'structural_element_id', + ]; + + parent::configure($config); + } +} diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php new file mode 100755 index 00000000000..fe61a2e618c --- /dev/null +++ b/lib/models/Courseware/Task.php @@ -0,0 +1,217 @@ +<?php + +namespace Courseware; + +use User; + +/** + * Courseware's tasks. + * + * @author Ron Lucke <lucke@elan-ev.de> + * @license GPL2 or any later version + * + * @since Stud.IP 5.1 + * + * @property int $id database column + * @property int $task_group_id database column + * @property int $structural_element_id database column + * @property string $solver_id database column + * @property string $solver_type database column + * @property int $submission_date database column + * @property int $submitted database column + * @property string $renewal database column + * @property int $renewal_date database column + * @property int $feedback_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property \Courseware\TaskGroup $task_group belongs_to Courseware\TaskGroup + * @property \Courseware\StructuralElement $structural_element belongs_to Courseware\TaskGroup + * @property \User $user belongs_to User + * @property \Statusgruppen $group belongs_to Statusgruppen + * @property \Courseware\TaskFeedback $task_feedback belongs_to Courseware\TaskFeedback + * @property-read \User|\Statusgruppen|null $solver belongs_to User or Statusgruppen + */ +class Task extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_tasks'; + + $config['belongs_to']['task_group'] = [ + 'class_name' => TaskGroup::class, + 'foreign_key' => 'task_group_id', + ]; + + $config['belongs_to']['structural_element'] = [ + 'class_name' => StructuralElement::class, + 'foreign_key' => 'structural_element_id', + ]; + + $config['belongs_to']['lecturer'] = [ + 'class_name' => User::class, + 'foreign_key' => 'lecturer_id', + ]; + + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'solver_id', + 'assoc_foreign_key' => 'user_id', + ]; + + $config['belongs_to']['group'] = [ + 'class_name' => \Statusgruppen::class, + 'foreign_key' => 'solver_id', + 'assoc_foreign_key' => 'statusgruppe_id', + ]; + + $config['belongs_to']['course'] = [ + 'class_name' => \Course::class, + 'foreign_key' => 'seminar_id', + ]; + + $config['belongs_to']['structural_element'] = [ + 'class_name' => StructuralElement::class, + 'foreign_key' => 'structural_element_id', + ]; + + $config['belongs_to']['task_feedback'] = [ + 'class_name' => TaskFeedback::class, + 'foreign_key' => 'feedback_id', + ]; + + $config['additional_fields']['solver'] = [ + 'get' => 'getSolver', + 'set' => false, + ]; + + parent::configure($config); + } + + /** + * Returns the structural element of this task. + * This structural element and all its children are part of the task. + * + * @return StructuralElement the structural element + */ + public function getStructuralElement(): StructuralElement + { + return $this->structural_element; + } + + /** + * Returns the feedback for this task. + * + * @return TaskFeedback the task feedback + */ + public function getFeedback(): ?TaskFeedback + { + return $this->task_feedback; + } + + /** + * @return bool true if task is submitted + */ + public function isSubmitted(): bool + { + return 1 === (int) $this->submitted; + } + + /** + * @param \User|\Seminar_User $user + */ + public function canUpdate($user): bool + { + $perm = false; + switch ($this->solver_type) { + case 'autor': + if ($this->solver_id === $user->id) { + return true; + } + break; + + case 'group': + $group = \Statusgruppen::find($this->solver_id); + if (isset($group) && $group->isMember($user->id)) { + return true; + } + break; + } + + return $this->getStructuralElement()->hasEditingPermission($user); + } + + /** + * @param \User|\Seminar_User $user + */ + public function userIsASolver($user): bool + { + switch ($this->solver_type) { + case 'autor': + return $this->solver_id === $user->id; + + case 'group': + $group = $this->getSolver(); + + return $group->isMember($user->id); + } + + return false; + } + + /** + * @return \User|\Statusgruppen|null the solver + */ + public function getSolver() + { + switch ($this->solver_type) { + case 'autor': + return \User::find($this->solver_id); + + case 'group': + return \Statusgruppen::find($this->solver_id); + } + + return null; + } + + public function getTaskProgress(): float + { + $children = $this->structural_element->findDescendants(); + + $element_counter = 1; + $progress = $this->getStructuralElementProgress($this->structural_element); + foreach ($children as $child) { + ++$element_counter; + $progress = ($this->getStructuralElementProgress($child) + $progress) / $element_counter; + } + + return $progress * 100; + } + + private function getStructuralElementProgress(StructuralElement $structural_element): float + { + $containers = Container::findBySQL('structural_element_id = ?', [intval($structural_element->id)]); + $counter = 0; + $progress = 0; + $b = []; + foreach ($containers as $container) { + $blockCount = $container->countBlocks(); + + $counter += $blockCount; + if ($blockCount > 0) { + $blocks = Block::findBySQL('container_id = ?', [$container->id]); + foreach ($blocks as $block) { + $b[] = $block->id; + if ($this->task_group->lecturer_id === $block->owner_id && $block->owner_id !== $block->editor_id) { + ++$progress; + } + } + } + } + if ($counter > 0) { + return $progress / $counter; + } + + return 0; + } +} diff --git a/lib/models/Courseware/TaskFeedback.php b/lib/models/Courseware/TaskFeedback.php new file mode 100755 index 00000000000..57e2ce07053 --- /dev/null +++ b/lib/models/Courseware/TaskFeedback.php @@ -0,0 +1,58 @@ +<?php + +namespace Courseware; + +use User; + +/** +* Courseware's task feedback. +* +* @author Ron Lucke <lucke@elan-ev.de> +* @license GPL2 or any later version +* +* @since Stud.IP 5.1 +* +* @property int $id database column +* @property int $task_id database column +* @property string $lecturer_id database column +* @property string $content database column +* @property int $mkdate database column +* @property int $chdate database column + +* @property \User $lecturer belongs_to User +* @property \Courseware\Task $task belongs_to Courseware\Task +*/ +class TaskFeedback extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_task_feedbacks'; + + $config['belongs_to']['lecturer'] = [ + 'class_name' => User::class, + 'foreign_key' => 'lecturer_id', + ]; + + $config['belongs_to']['task'] = [ + 'class_name' => Task::class, + 'foreign_key' => 'task_id', + ]; + + parent::configure($config); + } + + public function getStructuralElement(): ?StructuralElement + { + $sql = 'SELECT se.* + FROM cw_task_feedbacks tf + JOIN cw_tasks t ON t.id = tf.task_id + JOIN cw_structural_elements se ON se.id = t.structural_element_id + WHERE tf.id = ?'; + $structuralElement = \DBManager::get()->fetchOne($sql, [$this->getId()]); + if (!count($structuralElement)) { + return null; + } + + return StructuralElement::build($structuralElement, false); + } +} diff --git a/lib/models/Courseware/TaskGroup.php b/lib/models/Courseware/TaskGroup.php new file mode 100644 index 00000000000..7ca6eb1fa12 --- /dev/null +++ b/lib/models/Courseware/TaskGroup.php @@ -0,0 +1,61 @@ +<?php + +namespace Courseware; + +use User; + +/** + * Courseware's tasks. + * + * @author Ron Lucke <lucke@elan-ev.de> + * @license GPL2 or any later version + * + * @since Stud.IP 5.1 + * + * @property int $id database column + * @property string $seminar_id database column + * @property string $lecturer_id database column + * @property int $structural_element_id database column + * @property int $solver_may_add_blocks database column + * @property string $title database column + * @property int $mkdate database column + * @property int $chdate database column + * @property \User $lecturer belongs_to User + * @property \Course $course belongs_to Course + * @property \Courseware\StructuralElement $structural_element belongs_to Courseware\StructuralElement + * @property \SimpleORMapCollection $tasks has_many Courseware\Task + */ +class TaskGroup extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_task_groups'; + + $config['belongs_to']['lecturer'] = [ + 'class_name' => User::class, + 'foreign_key' => 'lecturer_id', + ]; + + $config['belongs_to']['course'] = [ + 'class_name' => \Course::class, + 'foreign_key' => 'seminar_id', + ]; + + $config['has_many']['tasks'] = [ + 'class_name' => Task::class, + 'assoc_foreign_key' => 'task_group_id', + 'on_delete' => 'delete', + 'on_store' => 'store', + 'order_by' => 'ORDER BY mkdate', + ]; + + parent::configure($config); + } + + public function getSolvers(): iterable + { + $solvers = $this->tasks->pluck('solver'); + + return $solvers; + } +} diff --git a/lib/models/Courseware/Template.php b/lib/models/Courseware/Template.php new file mode 100755 index 00000000000..8161d989ee9 --- /dev/null +++ b/lib/models/Courseware/Template.php @@ -0,0 +1,28 @@ +<?php + +namespace Courseware; + +/** +* Courseware's template. +* +* @author Ron Lucke <lucke@elan-ev.de> +* @license GPL2 or any later version +* +* @since Stud.IP 5.1 +* +* @property int $id database column +* @property string $name database column +* @property string $purpose database column +* @property string $structure database column +* @property int $mkdate database column +* @property int $chdate database column +*/ +class Template extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_templates'; + + parent::configure($config); + } +} \ No newline at end of file diff --git a/lib/navigation/AdminNavigation.php b/lib/navigation/AdminNavigation.php index 715f95fa777..915ceb3b48a 100644 --- a/lib/navigation/AdminNavigation.php +++ b/lib/navigation/AdminNavigation.php @@ -132,6 +132,13 @@ class AdminNavigation extends Navigation if (Config::get()->BANNER_ADS_ENABLE) { $navigation->addSubNavigation('banner', new Navigation(_('Werbebanner'), 'dispatch.php/admin/banner')); } + $navigation->addSubNavigation( + 'courseware', + new Navigation( + _('Courseware'), + 'dispatch.php/admin/courseware/index' + ) + ); if (Config::get()->OERCAMPUS_ENABLED) { $navigation->addSubNavigation( 'oer', diff --git a/lib/navigation/ContentsNavigation.php b/lib/navigation/ContentsNavigation.php index 0487ede97e8..767b86eed89 100755 --- a/lib/navigation/ContentsNavigation.php +++ b/lib/navigation/ContentsNavigation.php @@ -48,7 +48,7 @@ class ContentsNavigation extends Navigation $courseware->setImage(Icon::create('courseware')); $courseware->addSubNavigation( - 'projects', + 'overview', new Navigation(_('Übersicht'), 'dispatch.php/contents/courseware/index') ); $courseware->addSubNavigation( diff --git a/package.json b/package.json index 683e2c1039f..ea983208adc 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "vue-template-compiler": "^2.6.12", "vue-twentytwenty": "^0.10.1", "vue-typer": "^1.2.0", + "vuedraggable": "^2.24.3", "vuex": "^3.6.2", "webpack": "5.6.0", "webpack-cli": "4.2.0", diff --git a/public/assets/images/icons/black/bullet-arrow.svg b/public/assets/images/icons/black/bullet-arrow.svg new file mode 100644 index 00000000000..749a45eccc6 --- /dev/null +++ b/public/assets/images/icons/black/bullet-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#00000" d="M19 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/bullet-dot.svg b/public/assets/images/icons/black/bullet-dot.svg new file mode 100644 index 00000000000..846e6e49a53 --- /dev/null +++ b/public/assets/images/icons/black/bullet-dot.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><circle cx="27" cy="27" r="9" fill="#00000"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/bullet-double-arrow.svg b/public/assets/images/icons/black/bullet-double-arrow.svg new file mode 100644 index 00000000000..37f2642bbde --- /dev/null +++ b/public/assets/images/icons/black/bullet-double-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00000"><path d="M12.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/><path d="M25.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/bullet-line.svg b/public/assets/images/icons/black/bullet-line.svg new file mode 100644 index 00000000000..e8c0e8311a5 --- /dev/null +++ b/public/assets/images/icons/black/bullet-line.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#00000" d="M15 23h24v8H15z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/category-draft.svg b/public/assets/images/icons/black/category-draft.svg new file mode 100644 index 00000000000..9841a6101eb --- /dev/null +++ b/public/assets/images/icons/black/category-draft.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00000"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm26.2-14.5c-1.7-.9-3.8-.4-4.8 1.2l-.6 1-.9 1.5L21 31.4l.1 6.6 6-3.2L35 21.5l.9-1.5.6-1c1-1.5.4-3.6-1.3-4.5zm-3.8 3 .6-1c.5-.8 1.6-1.1 2.4-.6.8.5 1.1 1.5.6 2.3l-.6 1-3-1.7z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/category-others.svg b/public/assets/images/icons/black/category-others.svg new file mode 100644 index 00000000000..cba7cd90eb5 --- /dev/null +++ b/public/assets/images/icons/black/category-others.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00000"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="M28 18c4.4 0 8 3.6 8 8s-3.6 8-8 8-8-3.6-8-8 3.6-8 8-8m0-3c-6.1 0-11 4.9-11 11s4.9 11 11 11 11-5 11-11-4.9-11-11-11z" fill="#00000"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/category-portfolio.svg b/public/assets/images/icons/black/category-portfolio.svg new file mode 100644 index 00000000000..3c60df21244 --- /dev/null +++ b/public/assets/images/icons/black/category-portfolio.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00000"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm19-5.5c2.9 0 5.3-2.4 5.3-5.3s-2.4-5.3-5.3-5.3-5.3 2.4-5.3 5.3c0 3 2.4 5.3 5.3 5.3zm4.6 2.4h-9.1c-2 0-3.6 1.6-3.6 3.6V37h16.4v-7.5c-.1-2-1.7-3.6-3.7-3.6z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/category-task.svg b/public/assets/images/icons/black/category-task.svg new file mode 100644 index 00000000000..e8dd23601d3 --- /dev/null +++ b/public/assets/images/icons/black/category-task.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00000"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm31.1-8-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/category-template.svg b/public/assets/images/icons/black/category-template.svg new file mode 100644 index 00000000000..fdba7ab5aa7 --- /dev/null +++ b/public/assets/images/icons/black/category-template.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00000"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="m40.1 21-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z" fill="#00000"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/content2.svg b/public/assets/images/icons/black/content2.svg new file mode 100644 index 00000000000..abb38b21776 --- /dev/null +++ b/public/assets/images/icons/black/content2.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00000"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3z"/><path d="M32 33V22H21v-4h15v15z"/><path d="M34 20v11-11m4-4H19v8h11v11h8V16zM5 31h18v18H5z"/><path d="M21 33v14H7V33h14m4-4H3v22h22V29z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/bullet-arrow.svg b/public/assets/images/icons/blue/bullet-arrow.svg new file mode 100644 index 00000000000..474d6cf5cc4 --- /dev/null +++ b/public/assets/images/icons/blue/bullet-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#28497C" d="M19 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/bullet-dot.svg b/public/assets/images/icons/blue/bullet-dot.svg new file mode 100644 index 00000000000..a9874016463 --- /dev/null +++ b/public/assets/images/icons/blue/bullet-dot.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><circle cx="27" cy="27" r="9" fill="#28497C"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/bullet-double-arrow.svg b/public/assets/images/icons/blue/bullet-double-arrow.svg new file mode 100644 index 00000000000..c686f81a796 --- /dev/null +++ b/public/assets/images/icons/blue/bullet-double-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#28497C"><path d="M12.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/><path d="M25.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/bullet-line.svg b/public/assets/images/icons/blue/bullet-line.svg new file mode 100644 index 00000000000..fc53d9120d4 --- /dev/null +++ b/public/assets/images/icons/blue/bullet-line.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#28497C" d="M15 23h24v8H15z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/category-draft.svg b/public/assets/images/icons/blue/category-draft.svg new file mode 100644 index 00000000000..4efaccdba9a --- /dev/null +++ b/public/assets/images/icons/blue/category-draft.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#28497C"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm26.2-14.5c-1.7-.9-3.8-.4-4.8 1.2l-.6 1-.9 1.5L21 31.4l.1 6.6 6-3.2L35 21.5l.9-1.5.6-1c1-1.5.4-3.6-1.3-4.5zm-3.8 3 .6-1c.5-.8 1.6-1.1 2.4-.6.8.5 1.1 1.5.6 2.3l-.6 1-3-1.7z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/category-others.svg b/public/assets/images/icons/blue/category-others.svg new file mode 100644 index 00000000000..04ec1b4e9e4 --- /dev/null +++ b/public/assets/images/icons/blue/category-others.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#28497C"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="M28 18c4.4 0 8 3.6 8 8s-3.6 8-8 8-8-3.6-8-8 3.6-8 8-8m0-3c-6.1 0-11 4.9-11 11s4.9 11 11 11 11-5 11-11-4.9-11-11-11z" fill="#28497C"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/category-portfolio.svg b/public/assets/images/icons/blue/category-portfolio.svg new file mode 100644 index 00000000000..9e4069895b8 --- /dev/null +++ b/public/assets/images/icons/blue/category-portfolio.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#28497C"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm19-5.5c2.9 0 5.3-2.4 5.3-5.3s-2.4-5.3-5.3-5.3-5.3 2.4-5.3 5.3c0 3 2.4 5.3 5.3 5.3zm4.6 2.4h-9.1c-2 0-3.6 1.6-3.6 3.6V37h16.4v-7.5c-.1-2-1.7-3.6-3.7-3.6z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/category-task.svg b/public/assets/images/icons/blue/category-task.svg new file mode 100644 index 00000000000..639dd910f41 --- /dev/null +++ b/public/assets/images/icons/blue/category-task.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#28497C"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm31.1-8-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/category-template.svg b/public/assets/images/icons/blue/category-template.svg new file mode 100644 index 00000000000..852b4c3b5cb --- /dev/null +++ b/public/assets/images/icons/blue/category-template.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#28497C"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="m40.1 21-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z" fill="#28497C"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/content2.svg b/public/assets/images/icons/blue/content2.svg index 9d8d7123c55..ce93472c6a4 100644 --- a/public/assets/images/icons/blue/content2.svg +++ b/public/assets/images/icons/blue/content2.svg @@ -1 +1 @@ -<svg width="16" height="16" id="icons" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54"><defs><style>.cls-1{fill:#28497c;}</style></defs><g id="content"><rect class="cls-1" x="3" y="29.9" width="21.07" height="21.07"/><polygon class="cls-1" points="36.95 33.41 36.95 17.02 20.56 17.02 20.56 25.21 28.75 25.21 28.75 33.41 36.95 33.41"/><polygon class="cls-1" points="6.51 2.97 6.51 25.21 13.54 25.21 13.54 9.99 43.98 9.99 43.98 40.43 28.75 40.43 28.75 47.46 51 47.46 51 2.97 6.51 2.97"/></g></svg> \ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#28497C"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3z"/><path d="M32 33V22H21v-4h15v15z"/><path d="M34 20v11-11m4-4H19v8h11v11h8V16zM5 31h18v18H5z"/><path d="M21 33v14H7V33h14m4-4H3v22h22V29z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/bullet-arrow.svg b/public/assets/images/icons/green/bullet-arrow.svg new file mode 100644 index 00000000000..e6845b6bdb9 --- /dev/null +++ b/public/assets/images/icons/green/bullet-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#00962d" d="M19 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/bullet-dot.svg b/public/assets/images/icons/green/bullet-dot.svg new file mode 100644 index 00000000000..e045370eed6 --- /dev/null +++ b/public/assets/images/icons/green/bullet-dot.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><circle cx="27" cy="27" r="9" fill="#00962d"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/bullet-double-arrow.svg b/public/assets/images/icons/green/bullet-double-arrow.svg new file mode 100644 index 00000000000..ea9fdb175f0 --- /dev/null +++ b/public/assets/images/icons/green/bullet-double-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00962d"><path d="M12.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/><path d="M25.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/bullet-line.svg b/public/assets/images/icons/green/bullet-line.svg new file mode 100644 index 00000000000..809690d8c49 --- /dev/null +++ b/public/assets/images/icons/green/bullet-line.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#00962d" d="M15 23h24v8H15z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/category-draft.svg b/public/assets/images/icons/green/category-draft.svg new file mode 100644 index 00000000000..df4399e46af --- /dev/null +++ b/public/assets/images/icons/green/category-draft.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00962d"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm26.2-14.5c-1.7-.9-3.8-.4-4.8 1.2l-.6 1-.9 1.5L21 31.4l.1 6.6 6-3.2L35 21.5l.9-1.5.6-1c1-1.5.4-3.6-1.3-4.5zm-3.8 3 .6-1c.5-.8 1.6-1.1 2.4-.6.8.5 1.1 1.5.6 2.3l-.6 1-3-1.7z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/category-others.svg b/public/assets/images/icons/green/category-others.svg new file mode 100644 index 00000000000..6712133d18a --- /dev/null +++ b/public/assets/images/icons/green/category-others.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00962d"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="M28 18c4.4 0 8 3.6 8 8s-3.6 8-8 8-8-3.6-8-8 3.6-8 8-8m0-3c-6.1 0-11 4.9-11 11s4.9 11 11 11 11-5 11-11-4.9-11-11-11z" fill="#00962d"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/category-portfolio.svg b/public/assets/images/icons/green/category-portfolio.svg new file mode 100644 index 00000000000..a47558a419d --- /dev/null +++ b/public/assets/images/icons/green/category-portfolio.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00962d"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm19-5.5c2.9 0 5.3-2.4 5.3-5.3s-2.4-5.3-5.3-5.3-5.3 2.4-5.3 5.3c0 3 2.4 5.3 5.3 5.3zm4.6 2.4h-9.1c-2 0-3.6 1.6-3.6 3.6V37h16.4v-7.5c-.1-2-1.7-3.6-3.7-3.6z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/category-task.svg b/public/assets/images/icons/green/category-task.svg new file mode 100644 index 00000000000..f6912c52440 --- /dev/null +++ b/public/assets/images/icons/green/category-task.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00962d"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm31.1-8-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/category-template.svg b/public/assets/images/icons/green/category-template.svg new file mode 100644 index 00000000000..74bb961c99b --- /dev/null +++ b/public/assets/images/icons/green/category-template.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00962d"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="m40.1 21-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z" fill="#00962d"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/green/content2.svg b/public/assets/images/icons/green/content2.svg new file mode 100644 index 00000000000..39521421d37 --- /dev/null +++ b/public/assets/images/icons/green/content2.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#00962d"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3z"/><path d="M32 33V22H21v-4h15v15z"/><path d="M34 20v11-11m4-4H19v8h11v11h8V16zM5 31h18v18H5z"/><path d="M21 33v14H7V33h14m4-4H3v22h22V29z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/bullet-arrow.svg b/public/assets/images/icons/grey/bullet-arrow.svg new file mode 100644 index 00000000000..8fff0f178c8 --- /dev/null +++ b/public/assets/images/icons/grey/bullet-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#6e6e6e" d="M19 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/bullet-dot.svg b/public/assets/images/icons/grey/bullet-dot.svg new file mode 100644 index 00000000000..01510e40b80 --- /dev/null +++ b/public/assets/images/icons/grey/bullet-dot.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><circle cx="27" cy="27" r="9" fill="#6e6e6e"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/bullet-double-arrow.svg b/public/assets/images/icons/grey/bullet-double-arrow.svg new file mode 100644 index 00000000000..ceb9d9af651 --- /dev/null +++ b/public/assets/images/icons/grey/bullet-double-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#6e6e6e"><path d="M12.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/><path d="M25.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/bullet-line.svg b/public/assets/images/icons/grey/bullet-line.svg new file mode 100644 index 00000000000..1f5af73db30 --- /dev/null +++ b/public/assets/images/icons/grey/bullet-line.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#6e6e6e" d="M15 23h24v8H15z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/category-draft.svg b/public/assets/images/icons/grey/category-draft.svg new file mode 100644 index 00000000000..45b047475b3 --- /dev/null +++ b/public/assets/images/icons/grey/category-draft.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#6e6e6e"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm26.2-14.5c-1.7-.9-3.8-.4-4.8 1.2l-.6 1-.9 1.5L21 31.4l.1 6.6 6-3.2L35 21.5l.9-1.5.6-1c1-1.5.4-3.6-1.3-4.5zm-3.8 3 .6-1c.5-.8 1.6-1.1 2.4-.6.8.5 1.1 1.5.6 2.3l-.6 1-3-1.7z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/category-others.svg b/public/assets/images/icons/grey/category-others.svg new file mode 100644 index 00000000000..06f88711b72 --- /dev/null +++ b/public/assets/images/icons/grey/category-others.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#6e6e6e"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="M28 18c4.4 0 8 3.6 8 8s-3.6 8-8 8-8-3.6-8-8 3.6-8 8-8m0-3c-6.1 0-11 4.9-11 11s4.9 11 11 11 11-5 11-11-4.9-11-11-11z" fill="#6e6e6e"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/category-portfolio.svg b/public/assets/images/icons/grey/category-portfolio.svg new file mode 100644 index 00000000000..ee7d3b81f82 --- /dev/null +++ b/public/assets/images/icons/grey/category-portfolio.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#6e6e6e"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm19-5.5c2.9 0 5.3-2.4 5.3-5.3s-2.4-5.3-5.3-5.3-5.3 2.4-5.3 5.3c0 3 2.4 5.3 5.3 5.3zm4.6 2.4h-9.1c-2 0-3.6 1.6-3.6 3.6V37h16.4v-7.5c-.1-2-1.7-3.6-3.7-3.6z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/category-task.svg b/public/assets/images/icons/grey/category-task.svg new file mode 100644 index 00000000000..706598286a3 --- /dev/null +++ b/public/assets/images/icons/grey/category-task.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#6e6e6e"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm31.1-8-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/category-template.svg b/public/assets/images/icons/grey/category-template.svg new file mode 100644 index 00000000000..9294d4a39d0 --- /dev/null +++ b/public/assets/images/icons/grey/category-template.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#6e6e6e"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="m40.1 21-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z" fill="#6e6e6e"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/grey/content2.svg b/public/assets/images/icons/grey/content2.svg new file mode 100644 index 00000000000..3521bf93473 --- /dev/null +++ b/public/assets/images/icons/grey/content2.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#6e6e6e"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3z"/><path d="M32 33V22H21v-4h15v15z"/><path d="M34 20v11-11m4-4H19v8h11v11h8V16zM5 31h18v18H5z"/><path d="M21 33v14H7V33h14m4-4H3v22h22V29z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/bullet-arrow.svg b/public/assets/images/icons/red/bullet-arrow.svg new file mode 100644 index 00000000000..63b4f3c7f28 --- /dev/null +++ b/public/assets/images/icons/red/bullet-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#cb1800" d="M19 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/bullet-dot.svg b/public/assets/images/icons/red/bullet-dot.svg new file mode 100644 index 00000000000..4be587ba3bb --- /dev/null +++ b/public/assets/images/icons/red/bullet-dot.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><circle cx="27" cy="27" r="9" fill="#cb1800"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/bullet-double-arrow.svg b/public/assets/images/icons/red/bullet-double-arrow.svg new file mode 100644 index 00000000000..5502413f297 --- /dev/null +++ b/public/assets/images/icons/red/bullet-double-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#cb1800"><path d="M12.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/><path d="M25.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/bullet-line.svg b/public/assets/images/icons/red/bullet-line.svg new file mode 100644 index 00000000000..9f2cc6b5da1 --- /dev/null +++ b/public/assets/images/icons/red/bullet-line.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#cb1800" d="M15 23h24v8H15z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/category-draft.svg b/public/assets/images/icons/red/category-draft.svg new file mode 100644 index 00000000000..9e3e0f474c5 --- /dev/null +++ b/public/assets/images/icons/red/category-draft.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#cb1800"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm26.2-14.5c-1.7-.9-3.8-.4-4.8 1.2l-.6 1-.9 1.5L21 31.4l.1 6.6 6-3.2L35 21.5l.9-1.5.6-1c1-1.5.4-3.6-1.3-4.5zm-3.8 3 .6-1c.5-.8 1.6-1.1 2.4-.6.8.5 1.1 1.5.6 2.3l-.6 1-3-1.7z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/category-others.svg b/public/assets/images/icons/red/category-others.svg new file mode 100644 index 00000000000..918d2eac5ed --- /dev/null +++ b/public/assets/images/icons/red/category-others.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#cb1800"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="M28 18c4.4 0 8 3.6 8 8s-3.6 8-8 8-8-3.6-8-8 3.6-8 8-8m0-3c-6.1 0-11 4.9-11 11s4.9 11 11 11 11-5 11-11-4.9-11-11-11z" fill="#cb1800"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/category-portfolio.svg b/public/assets/images/icons/red/category-portfolio.svg new file mode 100644 index 00000000000..c8e9adc3010 --- /dev/null +++ b/public/assets/images/icons/red/category-portfolio.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#cb1800"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm19-5.5c2.9 0 5.3-2.4 5.3-5.3s-2.4-5.3-5.3-5.3-5.3 2.4-5.3 5.3c0 3 2.4 5.3 5.3 5.3zm4.6 2.4h-9.1c-2 0-3.6 1.6-3.6 3.6V37h16.4v-7.5c-.1-2-1.7-3.6-3.7-3.6z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/category-task.svg b/public/assets/images/icons/red/category-task.svg new file mode 100644 index 00000000000..3a9bee92e99 --- /dev/null +++ b/public/assets/images/icons/red/category-task.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#cb1800"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm31.1-8-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/category-template.svg b/public/assets/images/icons/red/category-template.svg new file mode 100644 index 00000000000..e25390b462c --- /dev/null +++ b/public/assets/images/icons/red/category-template.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#cb1800"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="m40.1 21-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z" fill="#cb1800"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/content2.svg b/public/assets/images/icons/red/content2.svg new file mode 100644 index 00000000000..0065efd12f8 --- /dev/null +++ b/public/assets/images/icons/red/content2.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#cb1800"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3z"/><path d="M32 33V22H21v-4h15v15z"/><path d="M34 20v11-11m4-4H19v8h11v11h8V16zM5 31h18v18H5z"/><path d="M21 33v14H7V33h14m4-4H3v22h22V29z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/bullet-arrow.svg b/public/assets/images/icons/white/bullet-arrow.svg new file mode 100644 index 00000000000..cc3da9fc930 --- /dev/null +++ b/public/assets/images/icons/white/bullet-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#fff" d="M19 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/bullet-dot.svg b/public/assets/images/icons/white/bullet-dot.svg new file mode 100644 index 00000000000..0620a65f9bb --- /dev/null +++ b/public/assets/images/icons/white/bullet-dot.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><circle cx="27" cy="27" r="9" fill="#fff"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/bullet-double-arrow.svg b/public/assets/images/icons/white/bullet-double-arrow.svg new file mode 100644 index 00000000000..67a3ae6ec4c --- /dev/null +++ b/public/assets/images/icons/white/bullet-double-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#fff"><path d="M12.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/><path d="M25.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/bullet-line.svg b/public/assets/images/icons/white/bullet-line.svg new file mode 100644 index 00000000000..1bafde988d1 --- /dev/null +++ b/public/assets/images/icons/white/bullet-line.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#fff" d="M15 23h24v8H15z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/category-draft.svg b/public/assets/images/icons/white/category-draft.svg new file mode 100644 index 00000000000..d1cd91e4f06 --- /dev/null +++ b/public/assets/images/icons/white/category-draft.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#fff"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm26.2-14.5c-1.7-.9-3.8-.4-4.8 1.2l-.6 1-.9 1.5L21 31.4l.1 6.6 6-3.2L35 21.5l.9-1.5.6-1c1-1.5.4-3.6-1.3-4.5zm-3.8 3 .6-1c.5-.8 1.6-1.1 2.4-.6.8.5 1.1 1.5.6 2.3l-.6 1-3-1.7z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/category-others.svg b/public/assets/images/icons/white/category-others.svg new file mode 100644 index 00000000000..0b4cbb450bd --- /dev/null +++ b/public/assets/images/icons/white/category-others.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#fff"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="M28 18c4.4 0 8 3.6 8 8s-3.6 8-8 8-8-3.6-8-8 3.6-8 8-8m0-3c-6.1 0-11 4.9-11 11s4.9 11 11 11 11-5 11-11-4.9-11-11-11z" fill="#fff"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/category-portfolio.svg b/public/assets/images/icons/white/category-portfolio.svg new file mode 100644 index 00000000000..d7d8faf4fc9 --- /dev/null +++ b/public/assets/images/icons/white/category-portfolio.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#fff"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm19-5.5c2.9 0 5.3-2.4 5.3-5.3s-2.4-5.3-5.3-5.3-5.3 2.4-5.3 5.3c0 3 2.4 5.3 5.3 5.3zm4.6 2.4h-9.1c-2 0-3.6 1.6-3.6 3.6V37h16.4v-7.5c-.1-2-1.7-3.6-3.7-3.6z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/category-task.svg b/public/assets/images/icons/white/category-task.svg new file mode 100644 index 00000000000..534657edc11 --- /dev/null +++ b/public/assets/images/icons/white/category-task.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#fff"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm31.1-8-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/category-template.svg b/public/assets/images/icons/white/category-template.svg new file mode 100644 index 00000000000..b7b1d044f10 --- /dev/null +++ b/public/assets/images/icons/white/category-template.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#fff"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="m40.1 21-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z" fill="#fff"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/content2.svg b/public/assets/images/icons/white/content2.svg new file mode 100644 index 00000000000..bca119506cd --- /dev/null +++ b/public/assets/images/icons/white/content2.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#fff"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3z"/><path d="M32 33V22H21v-4h15v15z"/><path d="M34 20v11-11m4-4H19v8h11v11h8V16zM5 31h18v18H5z"/><path d="M21 33v14H7V33h14m4-4H3v22h22V29z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/bullet-arrow.svg b/public/assets/images/icons/yellow/bullet-arrow.svg new file mode 100644 index 00000000000..7af94239dcd --- /dev/null +++ b/public/assets/images/icons/yellow/bullet-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#ffad00" d="M19 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/bullet-dot.svg b/public/assets/images/icons/yellow/bullet-dot.svg new file mode 100644 index 00000000000..644e1f89e0f --- /dev/null +++ b/public/assets/images/icons/yellow/bullet-dot.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><circle cx="27" cy="27" r="9" fill="#ffad00"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/bullet-double-arrow.svg b/public/assets/images/icons/yellow/bullet-double-arrow.svg new file mode 100644 index 00000000000..44855a8f6ec --- /dev/null +++ b/public/assets/images/icons/yellow/bullet-double-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#ffad00"><path d="M12.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/><path d="M25.5 11v8.1l7.9 7.9-7.9 7.9V43l16-16z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/bullet-line.svg b/public/assets/images/icons/yellow/bullet-line.svg new file mode 100644 index 00000000000..40abc52968d --- /dev/null +++ b/public/assets/images/icons/yellow/bullet-line.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><path fill="#ffad00" d="M15 23h24v8H15z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/category-draft.svg b/public/assets/images/icons/yellow/category-draft.svg new file mode 100644 index 00000000000..e9ec7ee2359 --- /dev/null +++ b/public/assets/images/icons/yellow/category-draft.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#ffad00"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm26.2-14.5c-1.7-.9-3.8-.4-4.8 1.2l-.6 1-.9 1.5L21 31.4l.1 6.6 6-3.2L35 21.5l.9-1.5.6-1c1-1.5.4-3.6-1.3-4.5zm-3.8 3 .6-1c.5-.8 1.6-1.1 2.4-.6.8.5 1.1 1.5.6 2.3l-.6 1-3-1.7z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/category-others.svg b/public/assets/images/icons/yellow/category-others.svg new file mode 100644 index 00000000000..58fa3570510 --- /dev/null +++ b/public/assets/images/icons/yellow/category-others.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#ffad00"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="M28 18c4.4 0 8 3.6 8 8s-3.6 8-8 8-8-3.6-8-8 3.6-8 8-8m0-3c-6.1 0-11 4.9-11 11s4.9 11 11 11 11-5 11-11-4.9-11-11-11z" fill="#ffad00"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/category-portfolio.svg b/public/assets/images/icons/yellow/category-portfolio.svg new file mode 100644 index 00000000000..bac2607f268 --- /dev/null +++ b/public/assets/images/icons/yellow/category-portfolio.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#ffad00"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm19-5.5c2.9 0 5.3-2.4 5.3-5.3s-2.4-5.3-5.3-5.3-5.3 2.4-5.3 5.3c0 3 2.4 5.3 5.3 5.3zm4.6 2.4h-9.1c-2 0-3.6 1.6-3.6 3.6V37h16.4v-7.5c-.1-2-1.7-3.6-3.7-3.6z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/category-task.svg b/public/assets/images/icons/yellow/category-task.svg new file mode 100644 index 00000000000..ddde9f909d0 --- /dev/null +++ b/public/assets/images/icons/yellow/category-task.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#ffad00"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29zm31.1-8-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/category-template.svg b/public/assets/images/icons/yellow/category-template.svg new file mode 100644 index 00000000000..9d5774ee1f2 --- /dev/null +++ b/public/assets/images/icons/yellow/category-template.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#ffad00"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3zM5 49V31h3.2L23 45.8V49z"/><path d="M7.3 33 21 46.6v.4H7V33h.3M9 29H3v22h22v-6L9 29z"/></g><path d="m40.1 21-4.2-4.2L25.6 27l-5.4-5.3-4.2 4.2 9.6 9.6L40.1 21z" fill="#ffad00"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/yellow/content2.svg b/public/assets/images/icons/yellow/content2.svg new file mode 100644 index 00000000000..3ec9dc681c6 --- /dev/null +++ b/public/assets/images/icons/yellow/content2.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54" xml:space="preserve"><g fill="#ffad00"><path d="M32 46v-2h15V7H10v15H8V5h41v41z"/><path d="M51 3H6v21h6V9h33v33H30v6h21V3z"/><path d="M32 33V22H21v-4h15v15z"/><path d="M34 20v11-11m4-4H19v8h11v11h8V16zM5 31h18v18H5z"/><path d="M21 33v14H7V33h14m4-4H3v22h22V29z"/></g></svg> \ No newline at end of file diff --git a/public/plugins_packages/core/ActivityFeed/ActivityFeed.php b/public/plugins_packages/core/ActivityFeed/ActivityFeed.php index a966e95ccb6..f42b34b2731 100644 --- a/public/plugins_packages/core/ActivityFeed/ActivityFeed.php +++ b/public/plugins_packages/core/ActivityFeed/ActivityFeed.php @@ -115,7 +115,8 @@ class ActivityFeed extends StudIPPlugin implements PortalPlugin 'wiki' => _('Wiki'), 'schedule' => _('Ablaufplan'), 'news' => _('Ankündigungen'), - 'blubber' => _('Blubber') + 'blubber' => _('Blubber'), + 'courseware' => _('Courseware') ]; $modules[\Context::INSTITUTE] = $modules[\Context::COURSE]; diff --git a/resources/assets/javascripts/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js index f9a85fe0700..2c2938d17d6 100755 --- a/resources/assets/javascripts/bootstrap/courseware.js +++ b/resources/assets/javascripts/bootstrap/courseware.js @@ -31,4 +31,37 @@ STUDIP.domReady(() => { }); }); } + + if (document.getElementById('courseware-content-overview-app')) { + STUDIP.Vue.load().then(({ createApp }) => { + import( + /* webpackChunkName: "courseware-content-overview-app" */ + '@/vue/courseware-content-overview-app.js' + ).then(({ default: mountApp }) => { + return mountApp(STUDIP, createApp, '#courseware-content-overview-app'); + }); + }); + } + + if (document.getElementById('courseware-content-bookmark-app')) { + STUDIP.Vue.load().then(({ createApp }) => { + import( + /* webpackChunkName: "courseware-content-bookmark-app" */ + '@/vue/courseware-content-bookmark-app.js' + ).then(({ default: mountApp }) => { + return mountApp(STUDIP, createApp, '#courseware-content-bookmark-app'); + }); + }); + } + + if (document.getElementById('courseware-admin-app')) { + STUDIP.Vue.load().then(({ createApp }) => { + import( + /* webpackChunkName: "courseware-content-bookmark-app" */ + '@/vue/courseware-admin-app.js' + ).then(({ default: mountApp }) => { + return mountApp(STUDIP, createApp, '#courseware-admin-app'); + }); + }); + } }); diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index cd4c5c83bf3..54f44004568 100755 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -10,6 +10,22 @@ $companion-types: ( pointing: pointing-right ); +$element-icons: ( + content: content2, + draft: category-draft, + task: category-task, + template: category-template, + oer: oer-campus, + other: category-others, + portfolio: category-portfolio +); + +$tree-item-flag-icons: ( + date: date, + write: edit, + cant-read: lock-locked2 +); + $tile-colors: ( black: #000, charcoal: #3c454e, @@ -76,6 +92,10 @@ $media-buttons: ( /* * * * * * * * c o n t e n t s * * * * * * * * */ +.cw-content-overview { + max-width: 1100px; +} + .cw-contents-overview-teaser { max-width: 782px; background-color: $content-color-20; @@ -122,6 +142,56 @@ c o n t e n t s .cw-loading-indicator-content { margin-top: 76px; } +.cw-content-loading { + /* Loading animation from activity feed */ + .loading-indicator { + text-align: center; + padding: 1em 0; + } + + .loading-indicator span { + background-color: #CCCCDD; + border-radius: 50%; + height: 10px; + position: relative; + width: 10px; + display: inline-block; + } + + .loading-indicator span.load-1 { + animation: loading-animation-1 1s linear 20; + } + + .loading-indicator span.load-2 { + animation: loading-animation-2 1s linear 20; + } + + .loading-indicator span.load-3 { + animation: loading-animation-3 1s linear 20; + } + + @keyframes loading-animation-1 { + 0% { transform: scale(1); } + 16% { transform: scale(1.3); } + 33% { transform: scale(1); } + 100% { transform: scale(1); } + } + + @keyframes loading-animation-2 { + 0% { transform: scale(1); } + 33% { transform: scale(1); } + 49% { transform: scale(1.3); } + 65% { transform: scale(1); } + 100% { transform: scale(1); } + } + + @keyframes loading-animation-3 { + 0% { transform: scale(1); } + 66% { transform: scale(1); } + 81% { transform: scale(1.3); } + 100% { transform: scale(1); } + } +} /* * * * * * * * * * * c o n t e n t s e n d @@ -502,18 +572,29 @@ ribbon end } + .cw-structural-element-discussion { + max-width: 1606px; + width: 100%; + margin-bottom: 1em; + } + .cw-container-wrapper { - max-width: 1115px; + max-width: 1095px; margin: 0; padding: 0; display: flex; flex-wrap: wrap; align-items: stretch; + justify-content: space-between; &.cw-container-wrapper-consume { margin: 0 auto; padding: 6em 1em 1em 1em; } + + &.cw-container-wrapper-discuss { + max-width: 1606px; + } } .cw-structural-element-description { @@ -647,7 +728,6 @@ ribbon end &.cw-container-colspan-half { max-width: 540px; width: 100%; - margin-right: 15px; } &.cw-container-colspan-half-center { width: 1095px; @@ -734,6 +814,24 @@ form.cw-container-dialog-edit-form { } } +.cw-container-wrapper-discuss { + flex-direction: column; + + .cw-container-colspan-full { + max-width: unset; + } + .cw-container-colspan-half-center, + .cw-container-colspan-half { + max-width: 1050px; + } + .cw-container-colspan-half-center { + width: 100%; + .cw-container-content { + width: 1050px; + } + } +} + /* * * * * * * container end * * * * * * */ @@ -766,6 +864,19 @@ form.cw-container-dialog-edit-form { padding: 0.5em; } } +.cw-container-wrapper-discuss { + .cw-container-colspan-full { + .cw-content-wrapper { + max-width: 1095px; + } + } + .cw-container-colspan-half, + .cw-container-colspan-half-center { + .cw-content-wrapper { + max-width: 540px; + } + } +} .cw-block-header { background-color: $content-color-20; max-height: 30px; @@ -788,6 +899,7 @@ form.cw-container-dialog-edit-form { } } +.cw-discuss-wrapper, .cw-block-features { header{ @@ -802,6 +914,44 @@ form.cw-container-dialog-edit-form { } } +.cw-discuss-wrapper { + flex-shrink: 3; + flex-grow: 2; + margin-left: 10px; +} + +@media only screen and (max-width: 1820px) { + .cw-structural-element .cw-container-wrapper.cw-container-wrapper-discuss { + max-width: 1095px; + .cw-container.cw-container-list.cw-container-item.cw-container-colspan-full { + .cw-default-block { + flex-flow: column; + .cw-discuss-wrapper { + margin-left: 0; + margin-top: 8px; + } + } + } + } +} + +@media only screen and (max-width: 1200px) { + .cw-structural-element .cw-container-wrapper.cw-container-wrapper-discuss { + max-width: 1095px; + .cw-container.cw-container-list.cw-container-item.cw-container-colspan-half, + .cw-container.cw-container-list.cw-container-item.cw-container-colspan-half-center { + .cw-default-block { + flex-flow: column; + .cw-discuss-wrapper { + margin-left: 0; + margin-top: 8px; + max-width: 540px; + } + } + } + } +} + .cw-button-feature-close { float: right; border: none; @@ -962,6 +1112,66 @@ label[for="cw-keypoint-color"] { block end * * * * * */ +/* * * * * * * * + sortable handle + * * * * * * * */ +.cw-container-list-sort-mode { + .block-ghost { + opacity: 0.6; + } + &.cw-container-list-sort-mode-empty { + min-height: 4em; + border: dashed thin $content-color-40; + } +} +.cw-structural-element-list-sort-mode { + list-style: none; + padding-left: 0; + + .cw-container-item-sortable { + border: solid thin $content-color-40; + background-color: $content-color-20; + color: $base-color; + font-weight: 700; + margin-bottom: 0.5em; + padding: 0.5em; + } + .container-ghost { + opacity: 0.6; + } +} +.cw-structural-element-list-sort-mode, +.cw-container-list-sort-mode { + .cw-sortable-handle { + display: inline-block; + cursor: grab; + background-image: url("#{$image-path}/anfasser_24.png"); + background-repeat: no-repeat; + width: 7px; + height: 24px; + padding-right: 4px; + vertical-align: middle; + } + .cw-content-wrapper-active:hover { + border: solid thin $base-color; + } +} + +.cw-container-item-sortable.sortable-chosen { + .cw-sortable-handle { + cursor: grabbing; + } +} + +.cw-container-sort-buttons { + display: block; +} + + +/* * * * * * * * * * * + sortable handle end + * * * * * * * * * * */ + /* * * * * t r e e * * * * */ @@ -972,6 +1182,7 @@ label[for="cw-keypoint-color"] { padding-left: 1.25em; margin-bottom: 20px; + &.cw-tree-subchapter-list, &.cw-tree-chapter-list, &.cw-tree-root-list { padding-left: 0; @@ -985,11 +1196,11 @@ label[for="cw-keypoint-color"] { padding-left: 3px; } } - .cw-tree-item-is-root{ + .cw-tree-item-is-root { display: block; font-size: 18px; .cw-tree-item-link { - padding-left: 24px; + padding-left: 26px; @include background-icon(courseware, clickable, 18); background-repeat: no-repeat; @@ -1007,26 +1218,48 @@ label[for="cw-keypoint-color"] { &:hover { background-color: $content-color-20; } + .cw-tree-item-link, + .cw-tree-item-link:hover, + .cw-tree-item-link.cw-tree-item-link-current { + background-image: none; + } + + @each $type, $icon in $element-icons { + &.cw-tree-item-#{$type} .cw-tree-item-link { + background-repeat: no-repeat; + background-position: 3px 3px; + padding-left: 26px; + @include background-icon(#{$icon}, clickable, 18); + &:hover { + @include background-icon(#{$icon}, attention, 18); + } + &.cw-tree-item-link-current { + @include background-icon(#{$icon}, info, 18); + } + } + } } .cw-tree-item-link { display: inline-block; width: calc(100% - 14px); text-align: justify; + background-repeat: no-repeat; + padding-left: 20px; + background-position: 4px 1px; + + @include background-icon(bullet-dot, clickable, 18); + &:hover { + @include background-icon(bullet-dot, attention, 18); + } + &.cw-tree-item-link-current { + @include background-icon(bullet-dot, info, 18); + } &:hover { background-color: $light-gray-color-20; color: $active-color; } - &::before { - content: '\2022'; - color: $base-color; - font-weight: 700; - width: 1em; - margin-left: -1em; - margin-right: 4px; - vertical-align: top; - } &.cw-tree-item-link-current { color: $black; @@ -1035,17 +1268,35 @@ label[for="cw-keypoint-color"] { color: $black; } } + @each $type, $icon in $tree-item-flag-icons { + .cw-tree-item-flag-#{$type} { + display: inline-block; + width: 16px; + height: 16px; + vertical-align: top; + @include background-icon(#{$icon}, clickable, 16); + } + &:hover .cw-tree-item-flag-#{$type} { + @include background-icon(#{$icon}, attention, 16); + } + &.cw-tree-item-link-current .cw-tree-item-flag-#{$type} { + @include background-icon(#{$icon}, info, 16); + } + } } - - } - - .cw-tree-item-first-level, - .cw-tree-item-is-root { - .cw-tree-item-link::before{ - content: ''; - width: 0; - margin: 0; + @each $type, $icon in $element-icons { + .cw-tree-item-#{$type} .cw-tree-item-link { + background-position: 0px 2px; + @include background-icon(#{$icon}, clickable, 16); + &:hover { + @include background-icon(#{$icon}, attention, 16); + } + &.cw-tree-item-link-current { + @include background-icon(#{$icon}, info, 16); + } + } } + } .cw-tree-item { @@ -1109,6 +1360,13 @@ c o l l a p s i b l e b o x } } +form .cw-collapsible .cw-collapsible-content.cw-collapsible-content-open { + padding: unset; + label { + margin: 1.5ex; + } +} + /* * * * * * * * * * * * * * * * * * c o l l a p s i b l e b o x e n d * * * * * * * * * * * * * * * * * */ @@ -1612,6 +1870,14 @@ c o m p a n i o n o v e r l a y background-image: url("#{$image-path}/companion/Tin_#{$image}.svg"); } } + + &.cw-companion-box-in-form { + margin-top: 8px; + } + + p { + margin: 0 1em 10px 0; + } } .cw-container-wrapper { @@ -1650,6 +1916,9 @@ v i e w w i d g e t .cw-action-widget-edit{ @include background-icon(edit, clickable); } + .cw-action-widget-sort{ + @include background-icon(arr_1sort, clickable); + } .cw-action-widget-add{ @include background-icon(add, clickable); } @@ -1665,6 +1934,9 @@ v i e w w i d g e t .cw-action-widget-export{ @include background-icon(export, clickable); } + .cw-action-widget-export-pdf{ + @include background-icon(file-pdf, clickable); + } .cw-action-widget-oer{ @include background-icon(oer-campus, clickable); } @@ -1678,20 +1950,28 @@ v i e w w i d g e t e n d c o m m e n t s & f e e d b a c k * * * * * * * * * * * * * * * * * * */ +.cw-structural-element-feedback, +.cw-structural-element-comments { + padding: 0 1em; +} + +.cw-structural-element-feedback-items, +.cw-structural-element-comments-items, .cw-block-feedback-items, .cw-block-comments-items { min-height: 1em; max-height: 225px; overflow-y: auto; overflow-x: hidden; - margin: -1em -1em 0em -0.5em; + margin: -1em -1em 0em 0em; + padding: 0em 1em 0em 0em; scroll-behavior: smooth; } .cw-talk-bubble { margin: 10px 20px; position: relative; - width: 80%; + width: 85%; height: auto; background-color: $dark-gray-color-10; border-radius: 5px; @@ -1753,6 +2033,8 @@ c o m m e n t s & f e e d b a c k } } +.cw-structural-element-feedback-create, +.cw-structural-element-comment-create, .cw-block-feedback-create, .cw-block-comment-create { border-top: solid thin $content-color-40; @@ -1760,6 +2042,21 @@ c o m m e n t s & f e e d b a c k textarea { width: calc(100% - 6px); resize: none; + border: solid thin $content-color-40; + &:active { + border: solid thin $content-color-80; + } + } +} +.cw-structural-element-comments-empty, +.cw-structural-element-feedback-empty, +.cw-block-comments-empty, +.cw-block-feedback-empty { + .cw-structural-element-feedback-create, + .cw-structural-element-comment-create, + .cw-block-feedback-create, + .cw-block-comment-create { + border-top: none; } } @@ -1893,8 +2190,7 @@ d a s h b o a r d .cw-dashboard { display: flex; - // TODO: Fixed width? - width: 1112px; + max-width: 1112px; flex-wrap: wrap; .cw-dashboard-box { @@ -1902,24 +2198,28 @@ d a s h b o a r d margin-right: 1em; &.cw-dashboard-box-full { - // TODO: Fixed width? - width: 1095px; + max-width: 1095px; + width: calc(100% - 16px); } &.cw-dashboard-box-half { - // TODO: Fixed width? - width: 540px; + width: calc(50% - 16px); + } + + &.cw-collapsible .cw-collapsible-content.cw-collapsible-content-open { + padding: 0; } } .cw-dashboard-overview { display: flex; - justify-content: space-evenly; - .cw-oblong { - margin-right: 1em; - } + padding: 10px; + flex-wrap: wrap; + justify-content: center; + } .cw-dashboard-progress { .cw-dashboard-progress-breadcrumb { + padding: 10px; span { color: $base-color; cursor: pointer; @@ -1932,6 +2232,7 @@ d a s h b o a r d .cw-dashboard-progress-chapter { text-align: center; + margin-bottom: -3.5em; h1 { border: none; @@ -1945,28 +2246,64 @@ d a s h b o a r d &.cw-dashboard-progress-current { font-size: 12px; - margin: -4.5em 0 2em 260px; + top: -4.5em; + left: -2.5em; } } } .cw-dashboard-progress-subchapter-list { border-top: solid thin $content-color-40; - margin: -0.5em; - height: 300px; + height: 349px; overflow-y: scroll; overflow-x: hidden; - padding: 1em; + 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; + flex-wrap: unset; + } + &.cw-dashboard-activity-view { + .cw-dashboard-activities { + max-height: 760px; + } + + } +} + +#course-courseware-dashboard { + .action-menu-item a { + cursor: pointer; + } +} + +.responsive-display { + .cw-dashboard { + .cw-dashboard-box { + + &.cw-dashboard-box-full { + width: 100% + } + &.cw-dashboard-box-half { + width: 100% + } } } } .cw-dashboard-progress-item { border-bottom: solid thin $content-color-40; - width: 492px; + width: 100%; cursor: pointer; + padding: 8px 0 8px 0; &:hover{ background-color: hsla(217,6%,45%,.2); @@ -1979,9 +2316,7 @@ d a s h b o a r d .cw-dashboard-progress-item-value, .cw-dashboard-progress-item-description { display: inline-block; - height: 70px; vertical-align: top; - line-height: 70px; } .cw-dashboard-progress-item-value { @@ -1995,46 +2330,95 @@ d a s h b o a r d } } .cw-dashboard-progress-item-description { - width: 404px; + width: calc(100% - 90px); 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: 476px; - list-style: none; - padding: 0; - margin: -0.5em; - scrollbar-width: thin; - scrollbar-color:$base-color #f5f5f5; - overflow-y: auto; - overflow-x: hidden; - - .cw-activity-item { - border-bottom: solid thin $content-color-40; - padding: 0.5em; + .cw-dashboard-activities { + max-height: 525px; + list-style: none; + padding: 0; + scrollbar-width: thin; + scrollbar-color:$base-color #f5f5f5; + overflow-y: auto; + overflow-x: hidden; - &:last-child { - border: none; - } + .cw-activity-item { + border-bottom: solid thin $content-color-40; + padding: 0.5em; - p { - margin: 0 0 4px 0; - img { - padding-right: 0.5em; - vertical-align: text-bottom; + &:last-child { + border: none; } - &.cw-activity-item-text { - padding-left: 23px; + + p { + margin: 0 0 4px 0; + img { + padding-right: 0.5em; + vertical-align: text-bottom; + } + &.cw-activity-item-text { + padding-left: 23px; + } } } - a{ + } +} + +.cw-dashboard-box { + .cw-dashboard-tasks-wrapper, + .cw-dashboard-students-wrapper { + padding: 10px; + } +} + +.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; + thead { + tr { + th { + &.feedback { + min-width: 11em; + } + &.renewal { + min-width: 14em; + } + } + } + } + tbody { + tr { + td { + img { + vertical-align: text-bottom; + &.display-feedback, + &.edit { + cursor: pointer; + } + } + } + } } } } @@ -2048,7 +2432,7 @@ o b l o n g * * * * * */ .cw-oblong-large { - border: solid thin $base-color; + border: solid thin $content-color-40; width: 520px; .cw-oblong-value, @@ -2061,25 +2445,29 @@ o b l o n g } .cw-oblong-value { - width: 90px; - color: $base-color; + width: 89px; + color: $black; + background-color: $content-color-20; + border-right: solid thin $content-color-40; font-size: xx-large; } .cw-oblong-description { width: 426px; - background-color: $base-color; - color: $white; + color: $black; + font-size: large; img { vertical-align: middle; - margin-right: 4px; + margin-right: 10px; } } } .cw-oblong-small { - border: solid thin $base-color; - width: 271px; + border: solid thin $content-color-40; + width: 340px; + margin-right: 1em; + margin-bottom: 5px; .cw-oblong-value, .cw-oblong-description { @@ -2091,15 +2479,17 @@ o b l o n g } .cw-oblong-value { - width: 60px; - background-color: $base-color; - color: $white; + width: 59px; + background-color: $content-color-20; + border-right: solid thin $content-color-40; + color: $black; font-size: x-large; } .cw-oblong-description { width: calc(100% - 64px); background-color: $white; - color: $base-color; + color: $black; + overflow: hidden; img { vertical-align: middle; margin-right: 8px; @@ -2321,9 +2711,7 @@ m a n a g e r } } - .cw-manager-element-subchapters { - } .cw-manager-element-item { border: solid thin $content-color-40; padding: 1em; @@ -2367,8 +2755,8 @@ m a n a g e r &.cw-manager-filing-active { @include background-icon(arr_eol-down, info-alt, 24); - background-color: $activity-color; - border: solid thin $activity-color; + background-color: $base-color; + border: solid thin $base-color; color: $white; } &.cw-manager-filing-disabled { @@ -3694,6 +4082,7 @@ headline block overflow: hidden; background-position: center; background-size: 1095px; + background-repeat: no-repeat; &.half { min-height: 300px; @@ -3861,8 +4250,8 @@ headline block } } } - -.cw-container-colspan-half { +.cw-container-colspan-half, +.cw-container-colspan-half-center { .cw-block-headline { .cw-block-headline-content { min-height: 300px; @@ -3997,6 +4386,23 @@ headline block } } +.responsive-display { + .cw-block-headline { + .cw-block-headline-content { + background-size: 100%; + &.bigicon_before { + .icon-layer { + background-size: 144px; + } + .cw-block-headline-textbox .cw-block-headline-title h1 { + font-size: 4em; + margin-left: 225px; + } + } + } + } +} + /* headline block end */ @@ -4004,6 +4410,12 @@ headline block end /* toc block */ +.cw-block-table-of-contents { + .cw-block-content { + overflow: unset; + } +} + .cw-block-table-of-contents-list { padding: 0; list-style: none; @@ -4124,6 +4536,7 @@ cw tiles } }; } + .preview-image { height: 180px; width: 100%; @@ -4135,14 +4548,16 @@ cw tiles @include background-icon(courseware, clickable, 128); } } + .description { height: 220px; - padding: 10px 14px; + padding: 14px; color: $white; position: relative; header { - font-size: 1.25em; + font-size: 20px; + line-height: 22px; color: $white; border: none; margin-bottom: 0.75em; @@ -4150,6 +4565,16 @@ cw tiles 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); + } + } } .description-text-wrapper { @@ -4160,11 +4585,11 @@ cw tiles -webkit-line-clamp: 7; -webkit-box-orient: vertical; p { - text-align: justify; + text-align: left; } } - footer{ + footer { width: 242px; text-align: right; color: $white; @@ -4232,3 +4657,60 @@ vSelect end } /* cw manager copy end*/ + +/* courseware template preview */ +.cw-template-preview { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + width: calc(100% - 20px);; + padding: 10px; + .cw-template-preview-container-wrapper { + margin-bottom: 10px; + + &.cw-template-preview-container-full { + width: 100% + } + &.cw-template-preview-container-half { + width: calc(50% - 4px); + } + &.cw-template-preview-container-half-center { + width: 100%; + .cw-template-preview-container-content { + width: 50%; + margin: auto; + } + } + + .cw-template-preview-container-content { + border: solid thin $content-color-40; + } + + .cw-template-preview-container-title { + font-weight: 700; + padding: 4px 4px 4px 8px; + color: $base-color; + background-color: $content-color-20; + } + + .cw-template-preview-blocks { + border: solid thin $content-color-40; + padding: 1em; + margin: 5px; + background-color: $white; + + } + } +} +/* courseware template preview end*/ + +/* contents courseware courses */ +.cw-content-courses { + h2 { + margin-top: 0; + } + ul.cw-tiles { + margin-bottom: 20px; + } +} +/* contents courseware courses end*/ diff --git a/resources/vue/components/courseware/AdminApp.vue b/resources/vue/components/courseware/AdminApp.vue new file mode 100755 index 00000000000..01bde3867f9 --- /dev/null +++ b/resources/vue/components/courseware/AdminApp.vue @@ -0,0 +1,33 @@ +<template> + <div class="cw-admin"> + <courseware-admin-templates v-if="templatesView" /> + <MountingPortal mountTo="#courseware-admin-view-widget" name="sidebar-views"> + <courseware-admin-view-widget /> + </MountingPortal> + <MountingPortal mountTo="#courseware-admin-action-widget" name="sidebar-views"> + <courseware-admin-action-widget /> + </MountingPortal> + + </div> +</template> + +<script> +import CoursewareAdminActionWidget from './CoursewareAdminActionWidget.vue'; +import CoursewareAdminTemplates from './CoursewareAdminTemplates.vue'; +import CoursewareAdminViewWidget from './CoursewareAdminViewWidget.vue'; +export default { + components: { + CoursewareAdminActionWidget, + CoursewareAdminTemplates, + CoursewareAdminViewWidget + }, + computed: { + adminViewMode() { + return this.$store.getters.adminViewMode; + }, + templatesView() { + return this.adminViewMode === 'templates'; + }, + }, +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/ContentBookmarkApp.vue b/resources/vue/components/courseware/ContentBookmarkApp.vue new file mode 100755 index 00000000000..b0c9fbc51d2 --- /dev/null +++ b/resources/vue/components/courseware/ContentBookmarkApp.vue @@ -0,0 +1,21 @@ +<template> + <div class="cw-content-bookmark"> + <courseware-content-bookmarks /> + <MountingPortal mountTo="#courseware-content-bookmark-filter-widget" name="sidebar-views"> + <courseware-content-bookmark-filter-widget /> + </MountingPortal> + </div> +</template> + +<script> +import CoursewareContentBookmarks from './CoursewareContentBookmarks.vue'; +import CoursewareContentBookmarkFilterWidget from './CoursewareContentBookmarkFilterWidget.vue'; + +export default { + components: { + CoursewareContentBookmarks, + CoursewareContentBookmarkFilterWidget + }, + +} +</script> diff --git a/resources/vue/components/courseware/ContentOverviewApp.vue b/resources/vue/components/courseware/ContentOverviewApp.vue new file mode 100755 index 00000000000..cad3fc0076a --- /dev/null +++ b/resources/vue/components/courseware/ContentOverviewApp.vue @@ -0,0 +1,25 @@ +<template> + <div class="cw-content-overview"> + <courseware-content-overview-elements /> + <MountingPortal mountTo="#courseware-content-overview-action-widget" name="sidebar-views"> + <courseware-content-overview-action-widget /> + </MountingPortal> + <MountingPortal mountTo="#courseware-content-overview-filter-widget" name="sidebar-views"> + <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/CoursewareAccordionContainer.vue b/resources/vue/components/courseware/CoursewareAccordionContainer.vue index 4a318c30f29..28f69aaba67 100755 --- a/resources/vue/components/courseware/CoursewareAccordionContainer.vue +++ b/resources/vue/components/courseware/CoursewareAccordionContainer.vue @@ -6,6 +6,7 @@ :isTeacher="isTeacher" @storeContainer="storeContainer" @closeEdit="initCurrentData" + @sortBlocks="enableSort" > <template v-slot:containerContent> <courseware-collapsible-box @@ -15,7 +16,7 @@ :icon="section.icon" :open="index === 0" > - <ul class="cw-container-accordion-block-list"> + <ul v-if="!sortMode" class="cw-container-accordion-block-list"> <li v-for="block in section.blocks" :key="block.id" class="cw-block-item"> <component :is="component(block)" @@ -24,11 +25,33 @@ :isTeacher="isTeacher" /> </li> - <li v-if="showEditMode"> + <li v-if="showEditMode && canAddElements"> <courseware-block-adder-area :container="container" :section="index" @updateContainerContent="updateContent"/> </li> </ul> + <draggable + v-if="sortMode && canEdit" + class="cw-container-list-block-list cw-container-list-sort-mode" + :class="[section.blocks.length === 0 ? 'cw-container-list-sort-mode-empty' : '']" + tag="ul" + v-model="section.blocks" + v-bind="dragOptions" + handle=".cw-sortable-handle" + @start="isDragging = true" + @end="isDragging = false" + > + <transition-group type="transition" name="flip-blocks" tag="div"> + <li v-for="block in section.blocks" :key="block.id" class="cw-block-item cw-block-item-sortable"> + <component :is="component(block)" :block="block" :canEdit="canEdit" :isTeacher="isTeacher" /> + </li> + </transition-group> + + </draggable> </courseware-collapsible-box> + <div v-if="sortMode && canEdit"> + <button class="button accept" @click="storeSort"><translate>Sortierung speichern</translate></button> + <button class="button cancel" @click="resetSort"><translate>Sortieren abbrechen</translate></button> + </div> </template> <template v-slot:containerEditDialog> <form class="default cw-container-dialog-edit-form" @submit.prevent=""> @@ -87,11 +110,20 @@ export default { container: Object, canEdit: Boolean, isTeacher: Boolean, + canAddElements: Boolean, }, data() { return { currentContainer: {}, currentSections: [], + sortMode: false, + isDragging: false, + dragOptions: { + animation: 0, + group: "description", + disabled: false, + ghostClass: "block-ghost" + }, }; }, computed: { @@ -118,6 +150,7 @@ export default { methods: { ...mapActions({ updateContainer: 'updateContainer', + lockObject: 'lockObject', unlockObject: 'unlockObject', }), initCurrentData() { @@ -163,7 +196,19 @@ export default { container: this.currentContainer, structuralElementId: this.currentContainer.relationships['structural-element'].data.id, }); - await this.unlockObject({ id: this.container.id, type: 'courseware-containers' }); + await this.unlockObject({ id: this.currentContainer.id, type: 'courseware-containers' }); + this.initCurrentData(); + }, + enableSort() { + this.sortMode = true; + }, + async storeSort() { + this.sortMode = false; + this.storeContainer(); + }, + async resetSort() { + await this.unlockObject({ id: this.currentContainer.id, type: 'courseware-containers' }); + this.sortMode = false; this.initCurrentData(); }, component(block) { diff --git a/resources/vue/components/courseware/CoursewareActionWidget.vue b/resources/vue/components/courseware/CoursewareActionWidget.vue index 7d94fc3a61a..4772bb2cc31 100644 --- a/resources/vue/components/courseware/CoursewareActionWidget.vue +++ b/resources/vue/components/courseware/CoursewareActionWidget.vue @@ -6,21 +6,29 @@ <li class="cw-action-widget-show-consume-mode" @click="showConsumeMode"> <translate>Vollbild einschalten</translate> </li> - <li v-show="canEdit" class="cw-action-widget-edit" @click="editElement"> + <li v-if="canEdit" class="cw-action-widget-edit" @click="editElement"> <translate>Seite bearbeiten</translate> </li> - <li v-show="canEdit" class="cw-action-widget-add" @click="addElement"> + <li v-if="canEdit" class="cw-action-widget-sort" @click="sortContainers"> + <translate>Abschnitte sortieren</translate> + </li> + <li v-if="canEdit" class="cw-action-widget-add" @click="addElement"> <translate>Seite hinzufügen</translate> </li> <li class="cw-action-widget-info" @click="showElementInfo"><translate>Informationen anzeigen</translate></li> <li class="cw-action-widget-star" @click="createBookmark"><translate>Lesezeichen setzen</translate></li> - <li v-show="canEdit" @click="exportElement" class="cw-action-widget-export"> + <li v-if="canExport" @click="exportElement" class="cw-action-widget-export"> <translate>Seite exportieren</translate> </li> - <li v-show="canEdit && oerEnabled" @click="oerElement" class="cw-action-widget-oer"> + <li v-if="(canEdit || userIsTeacher) && canVisit" class="cw-action-widget-export-pdf"> + <a :href="pdfExportURL"> + <translate>Seite als pdf-Dokument exportieren</translate> + </a> + </li> + <li v-if="canEdit && oerEnabled && userIsTeacher" @click="oerElement" class="cw-action-widget-oer"> <translate>Seite auf %{oerTitle} veröffentlichen</translate> </li> - <li v-show="!isRoot && canEdit" class="cw-action-widget-trash" @click="deleteElement"> + <li v-if="!isRoot && canEdit && !isTask" class="cw-action-widget-trash" @click="deleteElement"> <translate>Seite löschen</translate> </li> </ul> @@ -33,18 +41,23 @@ import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-action-widget', - props: ['structuralElement'], + props: ['structuralElement', 'canVisit'], components: { StudipIcon, }, mixins: [CoursewareExport], computed: { ...mapGetters({ + context: 'context', oerEnabled: 'oerEnabled', oerTitle: 'oerTitle', userId: 'userId', consumeMode: 'consumeMode', - showToolbar: 'showToolbar' + showToolbar: 'showToolbar', + userIsTeacher: 'userIsTeacher', + consumeMode: 'consumeMode', + showToolbar: 'showToolbar', + userIsTeacher: 'userIsTeacher', }), isRoot() { if (!this.structuralElement) { @@ -76,6 +89,49 @@ export default { }, tocText() { return this.showToolbar ? this.$gettext('Inhaltsverzeichnis ausblenden') : this.$gettext('Inhaltsverzeichnis anzeigen'); + }, + pdfExportURL() { + if (this.context.type === 'users') { + return STUDIP.URLHelper.getURL('dispatch.php/contents/courseware/pdf_export/' + this.structuralElement.id); + } + if (this.context.type === 'courses') { + return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/pdf_export/' + this.structuralElement.id); + } + + return ''; + }, + isTask() { + return this.structuralElement?.relationships.task.data !== null; + }, + canExport() { + if (this.context.type === 'users') { + return true; + } + + return this.canEdit && this.userIsTeacher; + }, + tocText() { + return this.showToolbar ? this.$gettext('Inhaltsverzeichnis ausblenden') : this.$gettext('Inhaltsverzeichnis anzeigen'); + }, + pdfExportURL() { + if (this.context.type === 'users') { + return STUDIP.URLHelper.getURL('dispatch.php/contents/courseware/pdf_export/' + this.structuralElement.id); + } + if (this.context.type === 'courses') { + return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/pdf_export/' + this.structuralElement.id); + } + + return ''; + }, + isTask() { + return this.structuralElement?.relationships.task.data !== null; + }, + canExport() { + if (this.context.type === 'users') { + return true; + } + + return this.canEdit && this.userIsTeacher; } }, methods: { @@ -86,6 +142,7 @@ export default { showElementInfoDialog: 'showElementInfoDialog', showElementExportDialog: 'showElementExportDialog', showElementOerDialog: 'showElementOerDialog', + setStructuralElementSortMode: 'setStructuralElementSortMode', companionInfo: 'companionInfo', addBookmark: 'addBookmark', lockObject: 'lockObject', @@ -112,6 +169,9 @@ export default { } this.showElementEditDialog(true); }, + sortContainers() { + this.setStructuralElementSortMode(true); + }, async deleteElement() { await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); this.showElementDeleteDialog(true); diff --git a/resources/vue/components/courseware/CoursewareActivityItem.vue b/resources/vue/components/courseware/CoursewareActivityItem.vue index 6b18bfea61e..8a7a4a9cb67 100755 --- a/resources/vue/components/courseware/CoursewareActivityItem.vue +++ b/resources/vue/components/courseware/CoursewareActivityItem.vue @@ -1,16 +1,16 @@ <template> <li class="cw-activity-item"> <p v-if="item.username" class="cw-activity-item-user"> - <a><studip-icon role="inactive" shape="headache" />{{ item.username }}</a> + <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> <p class="cw-activity-item-element"> - <a :href="item.element_id"><studip-icon role="inactive" :shape="shape" />{{ item.element_breadcrumb }}</a> + <a :href="linkUrl" :title="item.complete_breadcrumb"><studip-icon role="inactive" :shape="shape" />{{ item.element_breadcrumb }}</a> </p> - <p v-if="item.text" class="cw-activity-item-text"> - {{ item.text }} + <p v-if="text" class="cw-activity-item-text"> + <span v-html="text"></span> </p> </li> </template> @@ -27,17 +27,37 @@ export default { item: Object, }, computed: { + text() { + if (this.item.content == null || this.item.content == '') { + return this.item.text; + } + + 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; + } + }, + + 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; + }, shape() { switch (this.item.type) { - case 'comment': + case 'interacted': return 'item'; - case 'feedback': + case 'answered': return 'support'; - case 'new_block': - case 'new_element': + case 'created': return 'add'; - case 'updated_block': - case 'updated_element': + case 'edited': return 'edit'; default: return 'question-circle-full'; diff --git a/resources/vue/components/courseware/CoursewareAdminActionWidget.vue b/resources/vue/components/courseware/CoursewareAdminActionWidget.vue new file mode 100755 index 00000000000..90ba4255b31 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareAdminActionWidget.vue @@ -0,0 +1,30 @@ +<template> + <ul class="widget-list widget-links cw-action-widget"> + <li v-if="templatesView" class="cw-action-widget-add" @click="addTemplate"> + <translate>Vorlage hinzufügen</translate> + </li> + </ul> +</template> + +<script> + +export default { + name: 'courseware-admin-action-widget', + computed: { + adminViewMode() { + return this.$store.getters.adminViewMode; + }, + templatesView() { + return this.adminViewMode === 'templates'; + }, + showAddTemplateDialog() { + return this.$store.getters.showAddTemplateDialog; + }, + }, + methods: { + addTemplate() { + this.$store.dispatch('showAddTemplateDialog', true); + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareAdminTemplates.vue b/resources/vue/components/courseware/CoursewareAdminTemplates.vue new file mode 100755 index 00000000000..c237ebb6491 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareAdminTemplates.vue @@ -0,0 +1,237 @@ +<template> + <div class="cw-admin-templates"> + <table class="default"> + <caption> + <translate>Vorlagen</translate> + </caption> + <thead> + <tr> + <th><translate>Art des Lernmaterials</translate></th> + <th><translate>Name</translate></th> + <th class="actions"><translate>Aktionen</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="template in templates" :key="template.id"> + <td>{{ getPurposeName(template.attributes.purpose) }}</td> + <td>{{ template.attributes.name }}</td> + <td class="actions"> + <studip-action-menu + :items="menuItems" + @editTemplate="editTemplate(template.id)" + @deleteTemplate="confimDeleteTemplate(template.id)" + /> + </td> + </tr> + </tbody> + </table> + <studip-dialog + v-if="showAddTemplateDialog" + :title="$gettext('Vorlage hinzufügen')" + :confirmText="'Erstellen'" + :confirmClass="'accept'" + :closeText="$gettext('Schließen')" + :closeClass="'cancel'" + class="cw-admin-template-dialog" + height="360" + @close="closeAddDialog" + @confirm="createNewTemplate" + > + <template v-slot:dialogContent> + <form class="default" @submit.prevent=""> + <label> + <translate>Name der neuen Vorlage</translate> + <input v-model="newTemplateName" type="text" /> + </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>Vorlage</translate><br> + <button + class="button" + @click.prevent="chooseFile" + > + <translate>Vorlage-Archiv auswählen</translate> + </button> + <div v-if="importZip" class="cw-import-zip"> + <header>{{ importZip.name }}</header> + </div> + <input ref="importFile" type="file" accept=".zip" @change="setImport" style="visibility: hidden" /> + </label> + </form> + </template> + </studip-dialog> + <studip-dialog + v-if="showEditTemplateDialog" + :title="$gettext('Vorlage bearbeiten')" + :confirmText="'Speichern'" + :confirmClass="'accept'" + :closeText="$gettext('Schließen')" + :closeClass="'cancel'" + class="cw-admin-template-dialog" + @close="closeEditDialog" + @confirm="updateCurrentTemplate" + > + <template v-slot:dialogContent> + <form class="default" @submit.prevent=""> + <label> + <translate>Name der neuen Vorlage</translate> + <input v-model="currentTemplate.attributes.name" type="text" /> + </label> + <label> + <translate>Art des Lernmaterials</translate> + <select v-model="currentTemplate.attributes.purpose"> + <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> + </form> + </template> + </studip-dialog> + <studip-dialog + v-if="showDeleteDialog" + :title="$gettext('Vorlage löschen')" + :question="$gettext('Möchten Sie diese Vorlage wirklich löschen?')" + height="180" + @confirm="deleteCurrentTemplate" + @close="closeDeleteDialog" + ></studip-dialog> + </div> +</template> + +<script> +import StudipActionMenu from '../StudipActionMenu.vue'; +import StudipDialog from './../StudipDialog.vue'; + +import JSZip from 'jszip'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-admin-templates', + components: { + StudipActionMenu, + StudipDialog, + }, + data() { + return { + menuItems: [ + { id: 1, label: this.$gettext('Vorlage bearbeiten'), icon: 'edit', emit: 'editTemplate' }, + { id: 2, label: this.$gettext('Vorlage löschen'), icon: 'trash', emit: 'deleteTemplate' } + ], + newTemplateName: '', + newElementPurpose: '', + importZip: null, + zip: null, + showEditTemplateDialog: false, + currentTemplate: null, + showDeleteDialog: false, + } + }, + computed: { + ...mapGetters({ + templateById: 'courseware-templates/byId', + showAddTemplateDialog: 'showAddTemplateDialog', + templates: 'courseware-templates/all', + }), + }, + methods: { + ...mapActions({ + createTemplate: 'courseware-templates/create', + updateTemplate: 'courseware-templates/update', + deleteTemplate: 'courseware-templates/delete' + }), + closeAddDialog() { + this.$store.dispatch('showAddTemplateDialog', false); + this.newTemplateName = ''; + this.newElementPurpose = ''; + this.importZip = null; + this.zip = null; + }, + setImport(event) { + this.importZip = event.target.files[0]; + }, + chooseFile() { + this.$refs.importFile.click(); + }, + async createNewTemplate() { + let view = this; + let data = null; + this.zip = new JSZip(); + + await this.zip.loadAsync(this.importZip).then(async function () { + data = await view.zip.file('courseware.json').async('string'); + }); + + this.createTemplate({ + name: this.newTemplateName, + purpose: this.newElementPurpose, + structure: data + }); + + this.closeAddDialog(); + }, + editTemplate(templateId) { + this.currentTemplate = this.templateById({id: templateId}); + this.showEditTemplateDialog = true; + }, + closeEditDialog() { + this.showEditTemplateDialog = false; + this.currentTemplate = null; + }, + updateCurrentTemplate() { + this.updateTemplate({ + id: this.currentTemplate.id, + name: this.currentTemplate.attributes.name, + purpose: this.currentTemplate.attributes.purpose, + }); + this.closeEditDialog(); + }, + confimDeleteTemplate(templateId) { + this.currentTemplate = this.templateById({id: templateId}); + this.showDeleteDialog = true; + }, + deleteCurrentTemplate() { + this.deleteTemplate( { + id: this.currentTemplate.id + }); + this.closeDeleteDialog(); + }, + closeDeleteDialog() { + this.showDeleteDialog = false; + this.currentTemplate = null; + }, + getPurposeName(purpose) { + switch (purpose) { + case 'content': + return this.$gettext('Inhalt'); + case 'template': + return this.$gettext('Aufgabenvorlage'); + case 'oer': + return this.$gettext('OER-Material'); + case 'portfolio': + return this.$gettext('ePortfolio'); + case 'draft': + return this.$gettext('Entwurf'); + case 'other': + return this.$gettext('Sonstige'); + default: + return purpose; + } + } + } + +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareAdminViewWidget.vue b/resources/vue/components/courseware/CoursewareAdminViewWidget.vue new file mode 100755 index 00000000000..849d0705c55 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareAdminViewWidget.vue @@ -0,0 +1,31 @@ +<template> + <ul class="widget-list widget-links sidebar-views cw-view-widget"> + <li + :class="{ active: templatesView }" + @click="setTemplatesView" + > + <translate>Vorlagen</translate> + </li> + </ul> +</template> + +<script> + +export default { + name: 'courseware-admin-view-widget', + computed: { + adminViewMode() { + return this.$store.getters.adminViewMode; + }, + templatesView() { + return this.adminViewMode === 'templates'; + }, + }, + methods: { + setTemplatesView() { + this.$store.dispatch('adminViewMode', 'templates'); + }, + } + +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareBlockActions.vue b/resources/vue/components/courseware/CoursewareBlockActions.vue index 2c4c12b0361..fd689fa6395 100755 --- a/resources/vue/components/courseware/CoursewareBlockActions.vue +++ b/resources/vue/components/courseware/CoursewareBlockActions.vue @@ -4,8 +4,6 @@ :items="menuItems" @editBlock="editBlock" @setVisibility="setVisibility" - @showComments="showComments" - @showFeedback="showFeedback" @showInfo="showInfo" @deleteBlock="deleteBlock" /> @@ -31,9 +29,7 @@ export default { }, data() { return { - menuItems: [ - { id: 6, label: this.$gettext('Kommentare anzeigen'), icon: 'comment2', emit: 'showComments' }, - ], + menuItems: [], }; }, computed: { @@ -48,9 +44,6 @@ export default { }, }, mounted() { - if (this.deleteOnly) { - this.menuItems = []; - } if (this.canEdit) { if (!this.deleteOnly) { this.menuItems.push({ id: 1, label: this.$gettext('Block bearbeiten'), icon: 'edit', emit: 'editBlock' }); @@ -62,12 +55,6 @@ export default { icon: this.block.attributes.visible ? 'visibility-visible' : 'visibility-invisible', // do we change the icons ? emit: 'setVisibility', }); - this.menuItems.push({ - id: 5, - label: this.$gettext('Feedback anzeigen'), - icon: 'comment', - emit: 'showFeedback', - }); this.menuItems.push({ id: 7, label: this.$gettext('Informationen zum Block'), @@ -99,12 +86,6 @@ export default { editBlock() { this.$emit('editBlock'); }, - showFeedback() { - this.$emit('showFeedback'); - }, - showComments() { - this.$emit('showComments'); - }, showInfo() { this.$emit('showInfo'); }, diff --git a/resources/vue/components/courseware/CoursewareBlockComments.vue b/resources/vue/components/courseware/CoursewareBlockComments.vue index f182aa8168e..fb66627a07e 100755 --- a/resources/vue/components/courseware/CoursewareBlockComments.vue +++ b/resources/vue/components/courseware/CoursewareBlockComments.vue @@ -1,8 +1,7 @@ <template> - <section class="cw-block-comments"> - <header><translate>Kommentare</translate></header> + <section class="cw-block-comments" :class="[emptyComments ? 'cw-block-comments-empty' : '']"> <div class="cw-block-features-content"> - <div class="cw-block-comments-items" ref="comments"> + <div class="cw-block-comments-items" v-show="!emptyComments" ref="commentsRef"> <courseware-talk-bubble v-for="comment in comments" :key="comment.id" @@ -12,7 +11,6 @@ <div class="cw-block-comment-create"> <textarea v-model="createComment" :placeholder="placeHolder" spellcheck="true"></textarea> <button class="button" @click="postComment"><translate>Senden</translate></button> - <button class="button" @click="$emit('close')"><translate>Schließen</translate></button> </div> </div> </section> @@ -29,7 +27,6 @@ export default { }, props: { block: Object, - comments: Array, }, data() { return { @@ -41,21 +38,55 @@ export default { ...mapGetters({ relatedUser: 'users/related', userId: 'userId', + getComments: 'courseware-block-comments/related', }), + comments() { + const parent = { + type: this.block.type, + id: this.block.id, + }; + + return this.getComments({ parent, relationship: 'comments' }); + }, + emptyComments() { + if (this.comments === null || this.comments.length === 0) { + return true; + } + + return false; + } }, methods: { + async loadComments() { + const parent = { + type: this.block.type, + id: this.block.id, + }; + await this.$store.dispatch('courseware-block-comments/loadRelated', { + parent, + relationship: 'comments', + options: { + include: 'user', + }, + }); + }, async postComment() { - let data = {}; - data.attributes = {}; - data.attributes.comment = this.createComment; - data.relationships = {}; - data.relationships.block = {}; - data.relationships.block.data = {}; - data.relationships.block.data.id = this.block.id; - data.relationships.block.data.type = this.block.type; + const data = { + attributes: { + comment: this.createComment + }, + relationships: { + block: { + data: { + id: this.block.id, + type: this.block.type + } + } + } + }; await this.$store.dispatch('courseware-block-comments/create', data); - this.$emit('postComment'); + this.loadComments(); this.createComment = ''; }, buildPayload(comment) { @@ -78,8 +109,19 @@ export default { return payload; }, }, + mounted() { + this.loadComments(); + }, updated() { - this.$refs.comments.scrollTop = this.$refs.comments.scrollHeight; + let ref = this.$refs["commentsRef"]; + ref.scrollTop = ref.scrollHeight; }, + watch: { + comments() { + if (this.comments && this.comments.length > 0) { + this.$emit('hasComments'); + } + } + } }; </script> diff --git a/resources/vue/components/courseware/CoursewareBlockDiscussion.vue b/resources/vue/components/courseware/CoursewareBlockDiscussion.vue new file mode 100755 index 00000000000..e9ce9a3af5b --- /dev/null +++ b/resources/vue/components/courseware/CoursewareBlockDiscussion.vue @@ -0,0 +1,60 @@ +<template> + <div class="cw-block-discussion"> + <courseware-collapsible-box + :title="text.comments" + :open="hasComments" + > + <courseware-block-comments + :block="block" + @hasComments="hasComments = true" + /> + </courseware-collapsible-box> + + <courseware-collapsible-box + v-if="canEdit || userIsTeacher" + :title="text.feedback" + :open="hasFeedback" + > + <courseware-block-feedback + :block="block" + :canEdit="canEdit" + @hasFeedback="hasFeedback = true" + /> + </courseware-collapsible-box> + </div> +</template> + +<script> +import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; +import CoursewareBlockComments from './CoursewareBlockComments.vue'; +import CoursewareBlockFeedback from './CoursewareBlockFeedback.vue'; +import { mapGetters } from 'vuex'; + +export default { + name: 'courseware-block-discussion', + components: { + CoursewareCollapsibleBox, + CoursewareBlockComments, + CoursewareBlockFeedback, + }, + props: { + block: Object, + canEdit: Boolean + }, + data() { + return { + hasComments: false, + hasFeedback: false, + text: { + comments: this.$gettext('Kommentare'), + feedback: this.$gettext('Feedback') + } + } + }, + computed: { + ...mapGetters({ + userIsTeacher: 'userIsTeacher', + }), + } +} +</script> diff --git a/resources/vue/components/courseware/CoursewareBlockFeedback.vue b/resources/vue/components/courseware/CoursewareBlockFeedback.vue index 05ec040d17b..312eeb9caa4 100755 --- a/resources/vue/components/courseware/CoursewareBlockFeedback.vue +++ b/resources/vue/components/courseware/CoursewareBlockFeedback.vue @@ -1,36 +1,45 @@ <template> - <section class="cw-block-feedback"> - <header><translate>Feedback</translate></header> + <section + v-if="canEdit || userIsTeacher" + class="cw-block-feedback" + :class="[emptyFeedback ? 'cw-block-feedback-empty' : '']" + > <div class="cw-block-features-content"> - <div class="cw-block-feedback-items" ref="feedbacks"> + <div class="cw-block-feedback-items" v-show="!emptyFeedback" ref="feedbacks"> <courseware-talk-bubble v-for="feedback in feedback" :key="feedback.id" :payload="buildPayload(feedback)" /> </div> - <div class="cw-block-feedback-create"> + <courseware-companion-box + v-if="!userIsTeacher && feedback.length === 0" + :msgCompanion="$gettext('Es wurde noch kein Feedback abgegeben.')" + mood="pointing" + /> + <div v-if="userIsTeacher" class="cw-block-feedback-create"> <textarea v-model="feedbackText" :placeholder="placeHolder" spellcheck="true"></textarea> <button class="button" @click="postFeedback"><translate>Senden</translate></button> - <button class="button" @click="$emit('close')"><translate>Schließen</translate></button> </div> </div> </section> </template> <script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareTalkBubble from './CoursewareTalkBubble.vue'; -import { mapActions, mapGetters } from 'vuex'; +import { mapGetters } from 'vuex'; + export default { name: 'courseware-block-feedback', components: { + CoursewareCompanionBox, CoursewareTalkBubble, }, props: { block: Object, canEdit: Boolean, - isTeacher: Boolean, }, data() { return { @@ -43,18 +52,22 @@ export default { userId: 'userId', getRelatedFeedback: 'courseware-block-feedback/related', getRelatedUser: 'users/related', + userIsTeacher: 'userIsTeacher', }), feedback() { const { id, type } = this.block; return this.getRelatedFeedback({ parent: { id, type }, relationship: 'feedback' }); }, + emptyFeedback() { + if (this.feedback === null || this.feedback.length === 0) { + return true; + } + + return false; + } }, methods: { - ...mapActions({ - loadFeedback: 'loadFeedback', - createFeedback: 'createFeedback', - }), async postFeedback() { this.createFeedback({ blockId: this.block.id, feedback: this.feedbackText }); this.feedbackText = ''; @@ -72,6 +85,36 @@ export default { user_avatar: user?.meta?.avatar.small, }; }, + async loadFeedback() { + const parent = { + type: this.block.type, + id: this.block.id, + }; + await this.$store.dispatch('courseware-block-feedback/loadRelated', { + parent, + relationship: 'feedback', + options: { + include: 'user', + }, + }); + }, + async createFeedback() { + const data = { + attributes: { + feedback: this.feedbackText, + }, + relationships: { + block: { + data: { + type: this.block.type, + id: this.block.id, + }, + }, + }, + }; + await this.$store.dispatch('courseware-block-feedback/create', data, { root: true }); + this.loadFeedback(); + } }, async mounted() { await this.loadFeedback(this.block.id); @@ -79,5 +122,12 @@ export default { updated() { this.$refs.feedbacks.scrollTop = this.$refs.feedbacks.scrollHeight; }, + watch: { + feedback() { + if (this.feedback && this.feedback.length > 0) { + this.$emit('hasFeedback'); + } + } + } }; </script> diff --git a/resources/vue/components/courseware/CoursewareChartBlock.vue b/resources/vue/components/courseware/CoursewareChartBlock.vue index 4bce8b46c7f..b67d1edc505 100755 --- a/resources/vue/components/courseware/CoursewareChartBlock.vue +++ b/resources/vue/components/courseware/CoursewareChartBlock.vue @@ -62,7 +62,7 @@ <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <translate>Es steht keine Auswahl zur Verfügung.</translate> </template> <template #selected-option="{name, rgb}"> <span class="vs__option-color" :style="{'background-color': 'rgb(' + rgb + ')'}"></span><span>{{name}}</span> diff --git a/resources/vue/components/courseware/CoursewareCollapsibleBox.vue b/resources/vue/components/courseware/CoursewareCollapsibleBox.vue index 9021e2e1ec3..3bd0f40f6a8 100755 --- a/resources/vue/components/courseware/CoursewareCollapsibleBox.vue +++ b/resources/vue/components/courseware/CoursewareCollapsibleBox.vue @@ -34,5 +34,10 @@ export default { }; }, methods: {}, + watch: { + open(state) { + this.isOpen = state; + } + } }; </script> diff --git a/resources/vue/components/courseware/CoursewareContainerActions.vue b/resources/vue/components/courseware/CoursewareContainerActions.vue index be2c3dd209c..e1da9884676 100755 --- a/resources/vue/components/courseware/CoursewareContainerActions.vue +++ b/resources/vue/components/courseware/CoursewareContainerActions.vue @@ -4,6 +4,7 @@ :items="menuItems" @editContainer="editContainer" @deleteContainer="deleteContainer" + @sortBlocks="sortBlocks" /> </div> </template> @@ -18,11 +19,15 @@ export default { computed: { menuItems() { if (this.container.attributes["container-type"] === 'list') { - return [{ id: 1, label: this.$gettext('Abschnitt löschen'), icon: 'trash', emit: 'deleteContainer' }]; + return [ + { id: 1, label: this.$gettext('Blöcke sortieren'), icon: 'arr_1sort', emit: 'sortBlocks' }, + { id: 2, label: this.$gettext('Abschnitt löschen'), icon: 'trash', emit: 'deleteContainer' } + ]; } else { return [ { id: 1, label: this.$gettext('Abschnitt bearbeiten'), icon: 'edit', emit: 'editContainer' }, - { id: 2, label: this.$gettext('Abschnitt löschen'), icon: 'trash', emit: 'deleteContainer' }, + { id: 2, label: this.$gettext('Blöcke sortieren'), icon: 'arr_1sort', emit: 'sortBlocks' }, + { id: 3, label: this.$gettext('Abschnitt löschen'), icon: 'trash', emit: 'deleteContainer' }, ]; } }, @@ -37,6 +42,9 @@ export default { deleteContainer() { this.$emit('deleteContainer'); }, + sortBlocks() { + this.$emit('sortBlocks'); + } }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareContentBookmarkFilterWidget.vue b/resources/vue/components/courseware/CoursewareContentBookmarkFilterWidget.vue new file mode 100755 index 00000000000..de8eb010ce1 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareContentBookmarkFilterWidget.vue @@ -0,0 +1,42 @@ +<template> + <select v-model="bookmarkFilter" class="sidebar-selectlist"> + <option value="all"> + <translate>alle</translate> + </option> + <option v-for="course in courses" :key="course.id" :value="course.id"> + {{ course.attributes.title }} + </option> + </select> + +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-content-bookmark-filter-widget', + data() { + return { + bookmarkFilter: 'all' + }; + }, + computed: { + ...mapGetters({ + courses: 'courses/all', + }), + }, + methods: { + ...mapActions({ + setBookmarkFilter: 'setBookmarkFilter', + }), + setFilter() { + this.setBookmarkFilter(this.bookmarkFilter); + } + }, + watch: { + bookmarkFilter() { + this.setFilter(); + } + } +} +</script> diff --git a/resources/vue/components/courseware/CoursewareContentBookmarks.vue b/resources/vue/components/courseware/CoursewareContentBookmarks.vue new file mode 100755 index 00000000000..0718ff00813 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareContentBookmarks.vue @@ -0,0 +1,107 @@ +<template> + <div class="cw-bookmarks"> + <ul class="cw-tiles"> + <li + v-for="bookmark in sortedBookmarks" + :key="bookmark.id" + class="tile" + :class="[bookmark.attributes.payload.color, sortedBookmarks.length > 3 ? '': 'cw-tile-margin']" + > + <a :href="getElementUrl(bookmark)" :title="bookmark.attributes.title"> + <div + class="preview-image" + :class="[hasImage(child) ? '' : 'default-image']" + :style="getChildStyle(bookmark)" + ></div> + <div class="description"> + <header + :class="[bookmark.attributes.purpose !== '' ? 'description-icon-' + bookmark.attributes.purpose : '']" + > + {{ bookmark.attributes.title }} + </header> + <div class="description-text-wrapper"> + <p>{{ bookmark.attributes.payload.description }}</p> + </div> + <footer> + <span v-if="bookmark.relationships.course"> + <studip-icon shape="seminar" role="info_alt"/> {{ getCourseName(bookmark.relationships.course.data.id) }} + </span> + <span v-if="bookmark.relationships.user"> + <studip-icon shape="headache" role="info_alt"/> {{ getUserName(bookmark.relationships.user.data.id) }} + </span> + </footer> + </div> + </a> + </li> + </ul> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import StudipIcon from '../StudipIcon.vue' +export default { + name: 'courseware-content-bookmarks', + components: { + StudipIcon + }, + computed: { + ...mapGetters({ + courseById: 'courses/byId', + userById: 'users/byId', + userId: 'userId', + bookmarks: 'courseware-structural-elements/all', + bookmarkFilter: 'bookmarkFilter' + }), + sortedBookmarks() { + if (this.bookmarks) { + if (this.bookmarkFilter === 'all') { + return this.bookmarks; + } + return this.bookmarks.filter(bookmark => { + return bookmark.relationships.course.data.id === this.bookmarkFilter; + }); + } + } + }, + methods: { + ...mapActions({ + loadUser: 'users/loadById', + }), + getCourseName(cid) { + const course = this.courseById({id: cid}); + + return course.attributes.title; + }, + async getUserName(userId) { + await this.loadUser({id: userId}); + const user = this.userById({id: userId}); + + return user.attributes['formatted-name']; + }, + getElementUrl(element) { + if (element.relationships.course.data) { + let cid = element.relationships.course.data.id; + return STUDIP.URLHelper.base_url + 'dispatch.php/course/courseware/?cid='+ cid +'#/structural_element/' + element.id; + } + + return STUDIP.URLHelper.base_url + 'dispatch.php/contents/courseware/courseware#/structural_element/' + element.id; + }, + getChildStyle(element) { + let url = element.relationships?.image?.meta?.['download-url']; + + if (url) { + return {'background-image': 'url(' + url + ')'}; + } else { + return {}; + } + }, + hasImage(child) { + return child.relationships?.image?.data !== null; + }, + }, + + + +} +</script> diff --git a/resources/vue/components/courseware/CoursewareContentOverviewActionWidget.vue b/resources/vue/components/courseware/CoursewareContentOverviewActionWidget.vue new file mode 100755 index 00000000000..a5983cbe020 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareContentOverviewActionWidget.vue @@ -0,0 +1,23 @@ +<template> + <ul class="widget-list widget-links cw-action-widget"> + <li class="cw-action-widget-add" @click="addElement"> + <translate>Neues Lernmaterial anlegen</translate> + </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 new file mode 100755 index 00000000000..17e661c4bad --- /dev/null +++ b/resources/vue/components/courseware/CoursewareContentOverviewElements.vue @@ -0,0 +1,535 @@ +<template> +<div v-if="root"> + <ul class="cw-tiles"> + <li + v-for="child in filteredChildren" + :key="child.id" + class="tile" + :class="[child.attributes.payload.color, filteredChildren.length > 3 ? '': 'cw-tile-margin']" + > + <a :href="getElementUrl(child.id)" :title="child.attributes.title"> + <div + class="preview-image" + :class="[hasImage(child) ? '' : 'default-image']" + :style="getChildStyle(child)" + ></div> + <div class="description"> + <header + :class="[child.attributes.purpose !== '' ? 'description-icon-' + child.attributes.purpose : '']" + > + {{ child.attributes.title }} + </header> + <div class="description-text-wrapper"> + <p>{{ child.attributes.payload.description }}</p> + </div> + <footer> + {{ countChildren(child) }} + <translate + :translate-n="countChildren(child)" + translate-plural="Seiten" + > + Seite + </translate> + </footer> + </div> + </a> + </li> + </ul> + <courseware-companion-box v-if="children.length !== 0 && filteredChildren.length === 0 && purposeFilter !== 'all'" :msgCompanion="text.emptyFilter" mood="pointing"/> + <div v-if="children.length === 0" class="cw-contents-overview-teaser"> + <div class="cw-contents-overview-teaser-content"> + <header><translate>Ihre persönlichen Lernmaterialien</translate></header> + <p><translate>Erstellen und Verwalten Sie hier ihre eigenen persönlichen Lernmaterialien in Form von ePorfolios, + Vorlagen für Veranstaltungen oder einfach nur persönliche Inhalte für das Studium. + Entwickeln Sie ihre eigenen (Lehr-)Materialien für Studium oder die Lehre und teilen diese mit anderen Nutzenden.</translate></p> + <button class="button" @click="addElement"> + <translate>Neues Lernmaterial anlegen</translate> + </button> + </div> + </div> + <studip-dialog + v-if="showOverviewElementAddDialog" + :title="$gettext('Neues Lernmaterial anlegen')" + height="600" + width="500" + :confirmText="'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> + <v-select + v-model="newElement.attributes.payload.color" + :options="colors" + :reduce="(color) => color.class" + label="class" + class="cw-vs-select" + > + <template #open-indicator="selectAttributes"> + <span v-bind="selectAttributes" + ><studip-icon shape="arr_1down" size="10" + /></span> + </template> + <template #no-options="{ search, searching, loading }"> + <translate>Es steht keine Auswahl zur Verfügung.</translate> + </template> + <template #selected-option="{ name, hex }"> + <span class="vs__option-color" :style="{ 'background-color': hex }"></span + ><span>{{ name }}</span> + </template> + <template #option="{ name, hex }"> + <span class="vs__option-color" :style="{ 'background-color': hex }"></span + ><span>{{ name }}</span> + </template> + </v-select> + </label> + </form> + </courseware-collapsible-box> + + </template> + </studip-dialog> + <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 { + text: { + emptyFilter: this.$gettext('Für diese Auswahl wurden keine Lernmaterialien gefunden.'), + empty: this.$gettext('Es wurden keine Lernmaterialien gefunden.'), + }, + newElement: { + attributes: { + payload: {}, + }, + }, + newElementPurpose: 'content', + newElementTemplate: null, + uploadFileError: '', + } + }, + computed: { + ...mapGetters({ + getElement: 'courseware-structural-elements/byId', + licenses: 'licenses', + purposeFilter: 'purposeFilter', + showOverviewElementAddDialog: 'showOverviewElementAddDialog', + templates: 'courseware-templates/all', + }), + 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 (this.purposeFilter !== 'all') { + return this.children.filter(child => { return child.attributes.purpose === this.purposeFilter}); + } + return this.children; + }, + 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(element_id) { + return STUDIP.URLHelper.base_url + 'dispatch.php/contents/courseware/courseware#/structural_element/' + element_id; + }, + addElement() { + this.setShowOverviewElementAddDialog(true); + }, + closeAddDialog() { + this.setShowOverviewElementAddDialog(false); + this.initNewElement(); + }, + async createElement() { + 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 new file mode 100755 index 00000000000..3fd93ab5067 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareContentOverviewFilterWidget.vue @@ -0,0 +1,51 @@ +<template> + <select v-model="purposeFilter" class="sidebar-selectlist"> + <option value="all"> + <translate>alle</translate> + </option> + <option value="content"> + <translate>Inhalt</translate> + </option> + <option value="template"> + <translate>Aufgabenvorlage</translate> + </option> + <option value="oer"> + <translate>OER-Material</translate> + </option> + <option value="portfolio"> + <translate>ePortfolio</translate> + </option> + <option value="draft"> + <translate>Entwurf</translate> + </option> + <option value="other"> + <translate>Sonstiges</translate> + </option> + </select> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-content-overview-filter-widget', + data() { + return { + purposeFilter: 'all' + }; + }, + methods: { + ...mapActions({ + setPurposeFilter: 'setPurposeFilter' + }), + filterPurpose() { + this.setPurposeFilter(this.purposeFilter); + } + }, + watch: { + purposeFilter() { + this.filterPurpose(); + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareCourseDashboard.vue b/resources/vue/components/courseware/CoursewareCourseDashboard.vue index 15dc2930727..5f3b7ebed44 100755 --- a/resources/vue/components/courseware/CoursewareCourseDashboard.vue +++ b/resources/vue/components/courseware/CoursewareCourseDashboard.vue @@ -1,24 +1,40 @@ <template> - <div class="cw-dashboard cw-course-dashboard"> - <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"> - <courseware-dashboard-activities :activitiesList="activitiesList"></courseware-dashboard-activities> - </courseware-collapsible-box> + <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> @@ -26,7 +42,11 @@ 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', @@ -35,6 +55,9 @@ export default { CoursewareOblong, CoursewareDashboardProgress, CoursewareDashboardActivities, + CoursewareDashboardTasks, + CoursewareDashboardStudents, + CoursewareCompanionOverlay }, data() { return { @@ -44,14 +67,27 @@ export default { }; }, 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; }, - - activitiesList() { - // todo in 5.1 - return []; + defaultView() { + return this.dashboardViewMode === 'default'; }, - }, + taskView() { + return this.dashboardViewMode === 'task'; + }, + activityView() { + return this.dashboardViewMode === 'activity'; + }, + } }; </script> diff --git a/resources/vue/components/courseware/CoursewareCourseManager.vue b/resources/vue/components/courseware/CoursewareCourseManager.vue index eea091f8e1e..aed709d1c02 100755 --- a/resources/vue/components/courseware/CoursewareCourseManager.vue +++ b/resources/vue/components/courseware/CoursewareCourseManager.vue @@ -1,143 +1,149 @@ <template> - <div class="cw-course-manager"> - <courseware-tabs class="cw-course-manager-tabs"> - <courseware-tab :name="$gettext('Diese Courseware')" :selected="true"> - <courseware-manager-element - type="current" - :currentElement="currentElement" - @selectElement="setCurrentId" - @reloadElement="reloadElements" - /> - </courseware-tab> - <courseware-tab :name="$gettext('Export')"> - <button - class="button" - @click.prevent="doExportCourseware" - :class="{ - disabled: exportRunning, - }" - > - <translate>Alles exportieren</translate> - </button> - <courseware-companion-box v-show="exportRunning" :msgCompanion="$gettext('Export läuft, bitte haben sie einen Moment Geduld...')" mood="pointing"/> - <div v-if="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 class="cw-course-manager-wrapper"> + <div class="cw-course-manager"> + <courseware-tabs class="cw-course-manager-tabs"> + <courseware-tab :name="$gettext('Diese Courseware')" :selected="true"> + <courseware-manager-element + type="current" + :currentElement="currentElement" + @selectElement="setCurrentId" + @reloadElement="reloadElements" + /> + </courseware-tab> + <courseware-tab :name="$gettext('Export')"> + <button + class="button" + @click.prevent="doExportCourseware" + :class="{ + disabled: exportRunning, + }" + > + <translate>Alles exportieren</translate> + </button> + <courseware-companion-box v-show="exportRunning" :msgCompanion="$gettext('Export läuft, bitte haben sie einen Moment Geduld...')" mood="pointing"/> + <div v-if="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> - </div> - </courseware-tab> - </courseware-tabs> + </courseware-tab> + </courseware-tabs> - <courseware-tabs class="cw-course-manager-tabs"> - <courseware-tab :name="$gettext('FAQ')"> - <courseware-collapsible-box :open="true" :title="$gettext('Wie finde ich die gewünschte Stelle?')"> - <p><translate> - Wählen Sie auf der linken Seite "Diese Courseware" aus. - Beim laden der Seite ist dies immer gewählt. Die Überschrift - gibt an welche Seite Sie grade ausgewählt haben. Darunter befinden - sich die Abschnitte der Seite und innerhalb dieser dessen Blöcke. - Möchten Sie eine Seite die unterhalb der gewählten liegt bearbeiten, - können Sie diese über die Schaltflächen im Bereich "Seiten" wählen. - Über der Überschrift wird eine Navigation eingeblendet, mit dieser können - Sie beliebig weit hoch in der Hierarchie springen. - </translate></p> - </courseware-collapsible-box> - <courseware-collapsible-box :title="$gettext('Wie sortiere ich Objekte?')"> - <p><translate> - Seiten, Abschnitte und Blöcke lassen sich in ihrer Reihenfolge sortieren. - Hierzu wählen Sie auf der linken Seite unter "Diese Courseware" die Schaltfläche "Seiten sortieren", - "Abschnitte sortieren" oder "Blöcke sortieren". - An den Objekten werden Pfeile angezeigt, mit diesen können die Objekte an die gewünschte - Position gebracht werden. Um die neue Sortierung zu speichern wählen Sie "Sortieren beenden". - Sie können die Änderungen auch rückgängig machen indem Sie "Sortieren abbrechen" wählen. - </translate></p> - </courseware-collapsible-box> - <courseware-collapsible-box :title="$gettext('Wie verschiebe ich Objekte?')"> - <p><translate> - Seiten, Abschnitte und Blöcke lassen sich verschieben. - Hierzu wählen Sie auf der linken Seite unter "Diese Courseware" die Schaltfläche - "Seite an diese Stelle einfügen", "Abschnitt an diese Stelle einfügen" oder - "Block an diese Stelle einfügen". Wählen Sie dann auf der rechten Seite unter - "Verschieben" das Objekt aus das Sie verschieben möchten. Verschiebbare Objekte - erkennen Sie an den zwei nach links zeigenden gelben Pfeilen. - </translate></p> - </courseware-collapsible-box> - <courseware-collapsible-box :title="$gettext('Wie kopiere ich Objekte?')"> - <p><translate> - Seiten, Abschnitte und Blöcke lassen sich aus einer anderen Veranstaltung und Ihren - eigenen Inhalten kopieren. - Hierzu wählen Sie auf der linken Seite unter "Diese Courseware" die Schaltfläche - "Seite an diese Stelle einfügen", "Abschnitt an diese Stelle einfügen" oder - "Block an diese Stelle einfügen". Wählen Sie dann auf der rechten Seite unter - "Kopieren" erst die Veranstaltung aus der Sie kopieren möchten oder Ihre eigenen - Inhalte. Wählen sie dann das Objekt aus das Sie kopieren möchten. Kopierbare Objekte - erkennen Sie an den zwei nach links zeigenden gelben Pfeilen. - </translate></p> - </courseware-collapsible-box> - </courseware-tab> - <courseware-tab :name="$gettext('Verschieben')" :selected="true"> - <courseware-manager-element - type="self" - :currentElement="selfElement" - :moveSelfPossible="moveSelfPossible" - :moveSelfChildPossible="moveSelfChildPossible" - @selectElement="setSelfId" - @reloadElement="reloadElements" - /> - </courseware-tab> + <courseware-tabs class="cw-course-manager-tabs"> + <courseware-tab :name="$gettext('FAQ')"> + <courseware-collapsible-box :open="true" :title="$gettext('Wie finde ich die gewünschte Stelle?')"> + <p><translate> + Wählen Sie auf der linken Seite "Diese Courseware" aus. + Beim laden der Seite ist dies immer gewählt. Die Überschrift + gibt an, welche Seite Sie gerade ausgewählt haben. Darunter befinden + sich die Abschnitte der Seite und innerhalb dieser deren Blöcke. + Möchten Sie eine Seite, die unterhalb der gewählten liegt bearbeiten, + können Sie diese über die Schaltflächen im Bereich "Seiten" wählen. + Über der Überschrift wird eine Navigation eingeblendet, mit der Sie beliebig + weit hoch in der Hierarchie springen können. + </translate></p> + </courseware-collapsible-box> + <courseware-collapsible-box :title="$gettext('Wie sortiere ich Objekte?')"> + <p><translate> + Seiten, Abschnitte und Blöcke lassen sich in ihrer Reihenfolge sortieren. + Hierzu wählen Sie auf der linken Seite unter "Diese Courseware" die Schaltfläche "Seiten sortieren", + "Abschnitte sortieren" oder "Blöcke sortieren". + An den Objekten werden Pfeile angezeigt, mit diesen können die Objekte an die gewünschte + Position gebracht werden. Um die neue Sortierung zu speichern, wählen Sie "Sortieren beenden". + Sie können die Änderungen auch rückgängig machen, indem Sie "Sortieren abbrechen" wählen. + </translate></p> + </courseware-collapsible-box> + <courseware-collapsible-box :title="$gettext('Wie verschiebe ich Objekte?')"> + <p><translate> + Seiten, Abschnitte und Blöcke lassen sich verschieben. + Hierzu wählen Sie auf der linken Seite unter "Diese Courseware" die Schaltfläche + "Seite an diese Stelle einfügen", "Abschnitt an diese Stelle einfügen" oder + "Block an diese Stelle einfügen". Wählen Sie dann auf der rechten Seite unter + "Verschieben" das Objekt aus, das Sie verschieben möchten. Verschiebbare Objekte + erkennen Sie an den zwei nach links zeigenden gelben Pfeilen. + </translate></p> + </courseware-collapsible-box> + <courseware-collapsible-box :title="$gettext('Wie kopiere ich Objekte?')"> + <p><translate> + Seiten, Abschnitte und Blöcke lassen sich aus einer anderen Veranstaltung und Ihren + eigenen Inhalten kopieren. + Hierzu wählen Sie auf der linken Seite unter "Diese Courseware" die Schaltfläche + "Seite an diese Stelle einfügen", "Abschnitt an diese Stelle einfügen" oder + "Block an diese Stelle einfügen". Wählen Sie dann auf der rechten Seite unter + "Kopieren" erst die Veranstaltung aus der Sie kopieren möchten oder Ihre eigenen + Inhalte. Wählen sie dann das Objekt aus, das Sie kopieren möchten. Kopierbare Objekte + erkennen Sie an den zwei nach links zeigenden gelben Pfeilen. + </translate></p> + </courseware-collapsible-box> + </courseware-tab> + <courseware-tab name="Verschieben" :selected="true"> + <courseware-manager-element + type="self" + :currentElement="selfElement" + :moveSelfPossible="moveSelfPossible" + :moveSelfChildPossible="moveSelfChildPossible" + @selectElement="setSelfId" + @reloadElement="reloadElements" + /> + </courseware-tab> - <courseware-tab :name="$gettext('Kopieren')"> - <courseware-manager-copy-selector @loadSelf="reloadElements" @reloadElement="reloadElements" /> - </courseware-tab> + <courseware-tab :name="$gettext('Kopieren')"> + <courseware-manager-copy-selector @loadSelf="reloadElements" @reloadElement="reloadElements" /> + </courseware-tab> - <courseware-tab :name="$gettext('Importieren')"> - <courseware-companion-box v-show="!importRunning && importDone" :msgCompanion="$gettext('Import erfolgreich!')" mood="special"/> - <courseware-companion-box v-show="importRunning" :msgCompanion="$gettext('Import läuft. Bitte verlassen Sie die Seite nicht bis der Import abgeschlossen wurde.')" mood="pointing"/> - <button - v-show="!importRunning" - class="button" - @click.prevent="chooseFile" - > - <translate>Importdatei auswählen</translate> - </button> + <courseware-tab :name="$gettext('Importieren')"> + <courseware-companion-box v-show="!importRunning && importDone" :msgCompanion="$gettext('Import erfolgreich!')" mood="special"/> + <courseware-companion-box v-show="importRunning" :msgCompanion="$gettext('Import läuft. Bitte verlassen Sie die Seite nicht bis der Import abgeschlossen wurde.')" mood="pointing"/> + <button + v-show="!importRunning" + class="button" + @click.prevent="chooseFile" + > + <translate>Importdatei auswählen</translate> + </button> - <div v-if="importZip" class="cw-import-zip"> - <header>{{ importZip.name }}</header> - <p><translate>Größe</translate>: {{ getFileSizeText(importZip.size) }}</p> - </div> + <div v-if="importZip" class="cw-import-zip"> + <header>{{ importZip.name }}</header> + <p><translate>Größe</translate>: {{ getFileSizeText(importZip.size) }}</p> + </div> - <div v-if="importRunning" class="cw-import-zip"> - <header><translate>Importiere Dateien</translate>:</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 v-if="importRunning" class="cw-import-zip"> + <header><translate>Importiere Dateien</translate>:</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> - {{ importFilesState }} - </div> - <div v-if="fileImportDone && importRunning" class="cw-import-zip"> - <header><translate>Importiere Elemente</translate>:</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 v-if="fileImportDone && importRunning" class="cw-import-zip"> + <header><translate>Importiere Elemente</translate>:</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> - {{ importStructuresState }} - </div> - <button - v-show="importZip && !importRunning" - class="button" - @click.prevent="doImportCourseware" - > - <translate>Alles importieren</translate> - </button> + <button + v-show="importZip && !importRunning" + class="button" + @click.prevent="doImportCourseware" + > + <translate>Alles importieren</translate> + </button> - <ul v-if="importErrors.length > 0"> - <li v-for="error in importErrors"> {{error}} </li> - </ul> + <ul v-if="importErrors.length > 0"> + <li v-for="(index, error) in importErrors" :key="index"> {{error}} </li> + </ul> - <input ref="importFile" type="file" accept=".zip" @change="setImport" style="visibility: hidden" /> - </courseware-tab> - </courseware-tabs> + <input ref="importFile" type="file" accept=".zip" @change="setImport" style="visibility: hidden" /> + </courseware-tab> + <courseware-tab v-if="context.type === 'courses'" :name="$gettext('Aufgabe verteilen')"> + <courseware-manager-task-distributor /> + </courseware-tab> + </courseware-tabs> + </div> + <courseware-companion-overlay /> </div> </template> <script> @@ -146,6 +152,8 @@ import CoursewareTab from './CoursewareTab.vue'; import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; import CoursewareManagerElement from './CoursewareManagerElement.vue'; import CoursewareManagerCopySelector from './CoursewareManagerCopySelector.vue'; +import CoursewareManagerTaskDistributor from './CoursewareManagerTaskDistributor.vue'; +import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareImport from '@/vue/mixins/courseware/import.js'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; @@ -162,7 +170,9 @@ export default { CoursewareCollapsibleBox, CoursewareManagerElement, CoursewareManagerCopySelector, + CoursewareCompanionOverlay, CoursewareCompanionBox, + CoursewareManagerTaskDistributor }, mixins: [CoursewareImport, CoursewareExport], @@ -183,6 +193,7 @@ export default { computed: { ...mapGetters({ courseware: 'courseware', + context: 'context', structuralElementById: 'courseware-structural-elements/byId', importFilesState: 'importFilesState', importFilesProgress: 'importFilesProgress', diff --git a/resources/vue/components/courseware/CoursewareDashboardActivities.vue b/resources/vue/components/courseware/CoursewareDashboardActivities.vue index 3e9a8ee44a1..d068d3a8da7 100755 --- a/resources/vue/components/courseware/CoursewareDashboardActivities.vue +++ b/resources/vue/components/courseware/CoursewareDashboardActivities.vue @@ -1,19 +1,110 @@ <template> - <ul class="cw-dashboard-activities"> - <courseware-activity-item v-for="(item, index) in activitiesList" :key="index" :item="item" /> - </ul> + <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: { - activitiesList: Array, }, + 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/CoursewareDashboardProgress.vue b/resources/vue/components/courseware/CoursewareDashboardProgress.vue index e011c889195..0c32d825ea5 100755 --- a/resources/vue/components/courseware/CoursewareDashboardProgress.vue +++ b/resources/vue/components/courseware/CoursewareDashboardProgress.vue @@ -27,8 +27,11 @@ :chapterId="chapter.id" @selectChapter="selectChapter" /> - <div v-if="!children.length"> - <translate>Dieses Seite enthält keine darunter liegenden Seiten</translate> + <div v-if="!children.length" class="cw-dashboard-empty-info"> + <courseware-companion-box + mood="sad" + :msgCompanion="$gettext('Diese Seite enthält keine darunter liegenden Seiten.')" + /> </div> </div> </div> @@ -36,12 +39,14 @@ <script> import StudipIcon from '../StudipIcon.vue'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareDashboardProgressItem from './CoursewareDashboardProgressItem.vue'; import CoursewareProgressCircle from './CoursewareProgressCircle.vue'; export default { name: 'courseware-dashboard-progress', components: { + CoursewareCompanionBox, CoursewareDashboardProgressItem, CoursewareProgressCircle, StudipIcon, diff --git a/resources/vue/components/courseware/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/CoursewareDashboardStudents.vue new file mode 100755 index 00000000000..4e49327716b --- /dev/null +++ b/resources/vue/components/courseware/CoursewareDashboardStudents.vue @@ -0,0 +1,377 @@ +<template> + <div class="cw-dashboard-students-wrapper"> + <table v-if="tasks.length > 0" class="default"> + <colgroup> + <col /> + </colgroup> + <thead> + <tr> + <th><translate>Status</translate></th> + <th><translate>Aufgabentitel</translate></th> + <th><translate>Teilnehmende/Gruppen</translate></th> + <th><translate class="responsive-hidden">Seite</translate></th> + <th><translate>bearbeitet</translate></th> + <th><translate>Abgabefrist</translate></th> + <th><translate>Abgabe</translate></th> + <th class="responsive-hidden renewal"><translate>Verlängerungsanfrage</translate></th> + <th class="responsive-hidden feedback"><translate>Feedback</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="{ task, taskGroup, status, element, user, group, feedback } in tasks" :key="task.id"> + <td> + <studip-icon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + /> + </td> + <td> + {{ taskGroup && taskGroup.attributes.title }} + </td> + <td> + <span v-if="user"> + <studip-icon shape="person2" role="info" :title="$gettext('Teilnehmer/-in')" /> + {{ user.attributes['formatted-name'] }} + </span> + <span v-if="group"> + <studip-icon shape="group2" role="info" :title="$gettext('Gruppe')" /> + {{ group.attributes['name'] }} + </span> + </td> + <td class="responsive-hidden"> + <a v-if="task.attributes.submitted" :href="getLinkToElement(element.id)"> + {{ element.attributes.title }} + </a> + <span v-else>{{ element.attributes.title }}</span> + </td> + <td>{{ task.attributes.progress }}%</td> + <td>{{ getReadableDate(task.attributes['submission-date']) }}</td> + <td> + <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" /> + </td> + <td class="responsive-hidden"> + <button + v-show="task.attributes.renewal === 'pending'" + class="button" + @click="solveRenewalRequest(task)" + > + <translate>Anfrage bearbeiten</translate> + </button> + <span v-show="task.attributes.renewal === 'declined'"> + <studip-icon shape="decline" role="status-red" /> + <translate>Anfrage abgelehnt</translate> + </span> + <span v-show="task.attributes.renewal === 'granted'"> + <translate>verlängert bis</translate>: + {{ getReadableDate(task.attributes['renewal-date']) }} + </span> + <studip-icon + v-if="task.attributes.renewal === 'declined' || task.attributes.renewal === 'granted'" + :title="$gettext('Anfrage bearbeiten')" + class="edit" + shape="edit" + role="clickable" + @click="solveRenewalRequest(task)" + /> + </td> + <td class="responsive-hidden"> + <span + v-if="feedback" + :title=" + $gettext('Feedback geschrieben am:') + + ' ' + + getReadableDate(feedback.attributes['chdate']) + " + > + <studip-icon shape="accept" role="status-green" /> + <translate>Feedback gegeben</translate> + <studip-icon + :title="$gettext('Feedback bearbeiten')" + class="edit" + shape="edit" + role="clickable" + @click="editFeedback(feedback)" + /> + </span> + + <button + v-show="!feedback && task.attributes.submitted" + class="button" + @click="addFeedback(task)" + > + <translate>Feedback geben</translate> + </button> + </td> + </tr> + </tbody> + </table> + <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".') + " + > + <template v-slot:companionActions> + <a class="button" :href="managerUrl"><translate>Zur Verwaltung</translate></a> + </template> + </courseware-companion-box> + </div> + <studip-dialog + v-if="showRenewalDialog" + :title="text.renewalDialog.title" + :confirmText="text.renewalDialog.confirm" + :confirmClass="'accept'" + :closeText="text.renewalDialog.close" + :closeClass="'cancel'" + height="350" + @close=" + showRenewalDialog = false; + currentDialogTask = {}; + " + @confirm="updateRenewal" + > + <template v-slot:dialogContent> + <form class="default" @submit.prevent=""> + <label> + <translate>Fristverlängerung</translate> + <select v-model="currentDialogTask.attributes.renewal"> + <option value="declined"> + <translate>ablehnen</translate> + </option> + <option value="granted"> + <translate>gewähren</translate> + </option> + </select> + </label> + <label v-if="currentDialogTask.attributes.renewal === 'granted'"> + <translate>neue Frist</translate> + <courseware-date-input v-model="currentDialogTask.attributes['renewal-date']" class="size-l" /> + </label> + </form> + </template> + </studip-dialog> + <studip-dialog + v-if="showEditFeedbackDialog" + :title="text.editFeedbackDialog.title" + :confirmText="text.editFeedbackDialog.confirm" + :confirmClass="'accept'" + :closeText="text.editFeedbackDialog.close" + :closeClass="'cancel'" + height="420" + @close=" + showEditFeedbackDialog = false; + currentDialogFeedback = {}; + " + @confirm="updateFeedback" + > + <template v-slot:dialogContent> + <courseware-companion-box + v-if="currentDialogFeedback.attributes.content === ''" + mood="pointing" + :msgCompanion=" + $gettext('Sie haben kein Feedback geschrieben, beim Speichern wird dieses Feedback gelöscht!') + " + /> + <form class="default" @submit.prevent=""> + <label> + <translate>Feedback</translate> + <textarea v-model="currentDialogFeedback.attributes.content" /> + </label> + </form> + </template> + </studip-dialog> + <studip-dialog + v-if="showAddFeedbackDialog" + :title="text.addFeedbackDialog.title" + :confirmText="text.addFeedbackDialog.confirm" + :confirmClass="'accept'" + :closeText="text.addFeedbackDialog.close" + :closeClass="'cancel'" + @close=" + showAddFeedbackDialog = false; + currentDialogFeedback = {}; + " + @confirm="createFeedback" + > + <template v-slot:dialogContent> + <form class="default" @submit.prevent=""> + <label> + <translate>Feedback</translate> + <textarea v-model="currentDialogFeedback.attributes.content" /> + </label> + </form> + </template> + </studip-dialog> + </div> +</template> + +<script> +import StudipIcon from './../StudipIcon.vue'; +import StudipDialog from './../StudipDialog.vue'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import CoursewareDateInput from './CoursewareDateInput.vue'; +import taskHelperMixin from '../../mixins/courseware/task-helper.js'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-dashboard-students', + mixins: [taskHelperMixin], + components: { + CoursewareCompanionBox, + CoursewareDateInput, + StudipIcon, + StudipDialog, + }, + data() { + return { + showRenewalDialog: false, + showAddFeedbackDialog: false, + showEditFeedbackDialog: false, + currentDialogTask: {}, + currentDialogFeedback: {}, + text: { + renewalDialog: { + title: this.$gettext('Verlängerungsanfrage bearbeiten'), + confirm: this.$gettext('Speichern'), + close: this.$gettext('Schließen'), + }, + editFeedbackDialog: { + title: this.$gettext('Feedback zur Aufgabe ändern'), + confirm: this.$gettext('Speichern'), + close: this.$gettext('Schließen'), + }, + addFeedbackDialog: { + title: this.$gettext('Feedback zur Aufgabe geben'), + confirm: this.$gettext('Speichern'), + close: this.$gettext('Schließen'), + }, + }, + }; + }, + computed: { + ...mapGetters({ + context: 'context', + allTasks: 'courseware-tasks/all', + userById: 'users/byId', + statusGroupById: 'status-groups/byId', + getElementById: 'courseware-structural-elements/byId', + getFeedbackById: 'courseware-task-feedback/byId', + relatedTaskGroups: 'courseware-task-groups/related', + }), + tasks() { + return this.allTasks.map((task) => { + const result = { + task, + taskGroup: this.relatedTaskGroups({ parent: task, relationship: 'task-group' }), + status: this.getStatus(task), + element: this.getElementById({ id: task.relationships['structural-element'].data.id }), + user: null, + group: null, + feedback: null, + }; + let solver = task.relationships.solver.data; + if (solver.type === 'users') { + result.user = this.userById({ id: solver.id }); + } + if (solver.type === 'status-groups') { + result.group = this.statusGroupById({ id: solver.id }); + } + + const feedbackId = task.relationships['task-feedback'].data?.id; + if (feedbackId) { + result.feedback = this.getFeedbackById({ id: feedbackId }); + } + + return result; + }); + }, + managerUrl() { + return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/manager', {cid: this.context.id}); + } + }, + methods: { + ...mapActions({ + updateTask: 'updateTask', + createTaskFeedback: 'createTaskFeedback', + updateTaskFeedback: 'updateTaskFeedback', + deleteTaskFeedback: 'deleteTaskFeedback', + loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure', + copyStructuralElement: 'copyStructuralElement', + companionSuccess: 'companionSuccess', + companionError: 'companionError', + }), + addFeedback(task) { + this.currentDialogFeedback.attributes = {}; + this.currentDialogFeedback.attributes.content = ''; + this.currentDialogFeedback.relationships = {}; + this.currentDialogFeedback.relationships.task = {}; + this.currentDialogFeedback.relationships.task.data = {}; + this.currentDialogFeedback.relationships.task.data.id = task.id; + this.currentDialogFeedback.relationships.task.data.type = task.type; + this.showAddFeedbackDialog = true; + }, + createFeedback() { + if (this.currentDialogFeedback.attributes.content === '') { + this.companionError({ + info: this.$gettext('Bitte schreiben Sie ein Feedback.'), + }); + return false; + } + this.showAddFeedbackDialog = false; + this.createTaskFeedback({ + taskFeedback: this.currentDialogFeedback, + }); + this.currentDialogFeedback = {}; + }, + editFeedback(feedback) { + this.currentDialogFeedback = _.cloneDeep(feedback); + this.showEditFeedbackDialog = true; + }, + async updateFeedback() { + this.showEditFeedbackDialog = false; + let attributes = {}; + attributes.content = this.currentDialogFeedback.attributes.content; + if (attributes.content === '') { + await this.deleteTaskFeedback({ + taskFeedbackId: this.currentDialogFeedback.id, + }); + this.companionSuccess({ + info: this.$gettext('Feedback wurde gelöscht.'), + }); + } else { + await this.updateTaskFeedback({ + attributes: attributes, + taskFeedbackId: this.currentDialogFeedback.id, + }); + this.companionSuccess({ + info: this.$gettext('Feedback wurde gespeichert.'), + }); + } + + this.currentDialogFeedback = {}; + }, + solveRenewalRequest(task) { + this.currentDialogTask = _.cloneDeep(task); + this.showRenewalDialog = true; + }, + updateRenewal() { + this.showRenewalDialog = false; + let attributes = {}; + attributes.renewal = this.currentDialogTask.attributes.renewal; + if (attributes.renewal === 'granted') { + attributes['renewal-date'] = new Date(this.currentDialogTask.attributes['renewal-date']).toISOString(); + } + + this.updateTask({ + attributes: attributes, + taskId: this.currentDialogTask.id, + }); + this.currentDialogTask = {}; + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/CoursewareDashboardTasks.vue new file mode 100755 index 00000000000..02cc37e2525 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareDashboardTasks.vue @@ -0,0 +1,264 @@ +<template> + <div class="cw-dashboard-tasks-wrapper"> + <table v-if="tasks.length > 0" class="default"> + <colgroup> + <col /> + </colgroup> + <thead> + <tr> + <th><translate>Status</translate></th> + <th class="responsive-hidden"><translate>Aufgabentitel</translate></th> + <th><translate>Seite</translate></th> + <th><translate>bearbeitet</translate></th> + <th><translate>Abgabefrist</translate></th> + <th><translate>Abgabe</translate></th> + <th class="responsive-hidden"><translate>Verlängerungsanfrage</translate></th> + <th class="responsive-hidden"><translate>Feedback</translate></th> + <th><translate>Aktionen</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="{ task, taskGroup, status, element, feedback } in tasks" :key="task.id"> + <td> + <studip-icon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + /> + </td> + <td class="responsive-hidden"> + <studip-icon + v-if="task.attributes['solver-type'] === 'group'" + shape="group2" + role="info" + :title="$gettext('Gruppenaufgabe')" + /> + {{ taskGroup.attributes.title }} + </td> + <td> + <a :href="getLinkToElement(element.id)">{{ element.attributes.title }}</a> + </td> + <td>{{ task.attributes.progress }}%</td> + <td>{{ getReadableDate(task.attributes['submission-date']) }}</td> + <td> + <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" /> + </td> + <td class="responsive-hidden"> + <span v-show="task.attributes.renewal === 'declined'"> + <studip-icon shape="decline" role="status-red" /> + <translate>Anfrage abgelehnt</translate> + </span> + <span v-show="task.attributes.renewal === 'pending'"> + <studip-icon shape="date" role="status-yellow" /> + <translate>Anfrage wird bearbeitet</translate> + </span> + <span v-show="task.attributes.renewal === 'granted'"> + <translate>verlängert bis</translate>: {{getReadableDate(task.attributes['renewal-date'])}} + </span> + </td> + <td class="responsive-hidden"> + <studip-icon + v-if="feedback" + :title="$gettext('Feedback anzeigen')" + class="display-feedback" + shape="consultation" + role="clickable" + @click="displayFeedback(feedback)" + /> + </td> + <td class="actions"> + <studip-action-menu + :items="getTaskMenuItems(task, status)" + @submitTask="displaySubmitDialog(task)" + @renewalRequest="renewalRequest(task)" + @copyContent="copyContent(element)" + /> + </td> + </tr> + </tbody> + </table> + <div v-else> + <courseware-companion-box + mood="sad" + :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')" + /> + </div> + <studip-dialog + v-if="showFeedbackDialog" + :message="currentTaskFeedback" + :title="text.feedbackDialog.title" + @close=" + showFeedbackDialog = false; + currentTaskFeedback = ''; + " + /> + <studip-dialog + v-if="showSubmitDialog" + :title="text.submitDialog.title" + :question="text.submitDialog.question" + height="200" + width="420" + @confirm="submitTask" + @close="closeSubmitDialog" + /> + </div> +</template> +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import StudipIcon from './../StudipIcon.vue'; +import StudipActionMenu from './../StudipActionMenu.vue'; +import StudipDialog from './../StudipDialog.vue'; +import taskHelperMixin from '../../mixins/courseware/task-helper.js'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-dashboard-tasks', + mixins: [taskHelperMixin], + components: { + CoursewareCompanionBox, + StudipIcon, + StudipActionMenu, + StudipDialog, + }, + data() { + return { + showFeedbackDialog: false, + showSubmitDialog: false, + currentTask: null, + currentTaskFeedback: '', + text: { + feedbackDialog: { + title: this.$gettext('Feedback'), + }, + submitDialog: { + title: this.$gettext('Aufgabe abgeben'), + question: this.$gettext( + 'Änderungen sind nach Abgabe nicht mehr möglich. Möchten Sie diese Aufgabe jetzt wirklich abgeben?' + ), + }, + }, + }; + }, + computed: { + ...mapGetters({ + context: 'context', + allTasks: 'courseware-tasks/all', + userId: 'userId', + userById: 'users/byId', + statusGroupById: 'status-groups/byId', + getElementById: 'courseware-structural-elements/byId', + getFeedbackById: 'courseware-task-feedback/byId', + getTaskGroupById: 'courseware-task-groups/byId', + }), + tasks() { + return this.allTasks.map((task) => { + const result = { + task, + taskGroup: this.getTaskGroupById({ id: task.relationships['task-group'].data.id }), + status: this.getStatus(task), + element: this.getElementById({ id: task.relationships['structural-element'].data.id }), + feedback: null, + }; + const feedbackId = task.relationships['task-feedback'].data?.id; + if (feedbackId) { + result.feedback = this.getFeedbackById({ id: feedbackId }); + } + + return result; + }); + }, + }, + methods: { + ...mapActions({ + updateTask: 'updateTask', + loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure', + copyStructuralElement: 'copyStructuralElement', + companionSuccess: 'companionSuccess', + companionError: 'companionError', + }), + getTaskMenuItems(task, status) { + let menuItems = []; + if (!task.attributes.submitted && status.canSubmit) { + menuItems.push({ id: 1, label: this.$gettext('Aufgabe abgeben'), icon: 'service', emit: 'submitTask' }); + } + + if (!task.attributes.submitted && !task.attributes.renewal) { + menuItems.push({ + id: 2, + label: this.$gettext('Verlängerung beantragen'), + icon: 'date', + emit: 'renewalRequest', + }); + } + if (task.attributes.submitted) { + menuItems.push({ id: 3, label: this.$gettext('Inhalt kopieren'), icon: 'export', emit: 'copyContent' }); + } + + return menuItems; + }, + async renewalRequest(task) { + let attributes = {}; + attributes.renewal = 'pending'; + await this.updateTask({ + attributes: attributes, + taskId: task.id, + }); + this.companionSuccess({ + info: this.$gettext('Ihre Anfrage wurde eingereicht.'), + }); + }, + displaySubmitDialog(task) { + this.showSubmitDialog = true; + this.currentTask = task; + }, + closeSubmitDialog() { + this.showSubmitDialog = false; + this.currentTask = null; + }, + async submitTask() { + this.showSubmitDialog = false; + let attributes = {}; + attributes.submitted = true; + await this.updateTask({ + attributes: attributes, + taskId: this.currentTask.id, + }); + this.companionSuccess({ + info: + '"' + + this.currentTask.attributes.title + + '" ' + + this.$gettext('wurde erfolgreich abgegeben.'), + }); + this.currentTask = null; + }, + async copyContent(element) { + let ownCoursewareInstance = await this.loadRemoteCoursewareStructure({ + rangeId: this.userId, + rangeType: 'users', + }); + if (ownCoursewareInstance !== null) { + await this.copyStructuralElement({ + parentId: ownCoursewareInstance.relationships.root.data.id, + element: element, + removeType: true, + }); + this.companionSuccess({ + info: this.$gettext('Die Inhalte wurden zu Ihren persönlichen Lernmaterialien hinzugefügt.'), + }); + } else { + this.companionError({ + info: this.$gettext( + 'Die Inhalte konnten nicht zu Ihren persönlichen Lernmaterialien hinzugefügt werden.' + ), + }); + } + }, + displayFeedback(feedback) { + this.showFeedbackDialog = true; + this.currentTaskFeedback = feedback.attributes.content; + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/CoursewareDashboardViewWidget.vue b/resources/vue/components/courseware/CoursewareDashboardViewWidget.vue new file mode 100755 index 00000000000..4b72b5b9f8a --- /dev/null +++ b/resources/vue/components/courseware/CoursewareDashboardViewWidget.vue @@ -0,0 +1,59 @@ +<template> + <ul class="widget-list widget-links sidebar-views cw-view-widget"> + <li + :class="{ active: defaultView }" + @click="setDefaultView" + > + <translate>Standard</translate> + </li> + <li + :class="{ active: taskView }" + @click="setTaskView" + > + <translate>Aufgaben</translate> + </li> + <li + :class="{ active: activityView }" + @click="setActivityView" + > + <translate>Aktivitäten</translate> + </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/CoursewareDateInput.vue b/resources/vue/components/courseware/CoursewareDateInput.vue new file mode 100644 index 00000000000..57a5619fbee --- /dev/null +++ b/resources/vue/components/courseware/CoursewareDateInput.vue @@ -0,0 +1,34 @@ +<template> + <input :value="formattedDate" @input="onInput" type="date" /> +</template> + +<script> +const fromISO8601 = (string) => new Date(string); +const toISO8601 = (date) => date.toISOString(); +const pad = (what, length = 2) => `00000000${what}`.substr(-length); + +export default { + props: ['value'], + data: () => ({ + date: new Date(), + }), + computed: { + formattedDate() { + return `${this.date.getFullYear()}-${pad(this.date.getMonth() + 1)}-${pad(this.date.getDate())}`; + }, + }, + methods: { + onInput({ target }) { + const newValue = toISO8601(target.valueAsDate); + if (newValue !== this.value) { + this.$emit('input', newValue); + } + }, + }, + beforeMount() { + if (this.value) { + this.date = fromISO8601(this.value); + } + }, +}; +</script> diff --git a/resources/vue/components/courseware/CoursewareDefaultBlock.vue b/resources/vue/components/courseware/CoursewareDefaultBlock.vue index 02d2a228c10..458739901c6 100755 --- a/resources/vue/components/courseware/CoursewareDefaultBlock.vue +++ b/resources/vue/components/courseware/CoursewareDefaultBlock.vue @@ -2,6 +2,7 @@ <div v-if="block.attributes.visible || canEdit" class="cw-default-block"> <div class="cw-content-wrapper" :class="[showEditMode ? 'cw-content-wrapper-active' : '']"> <header v-if="showEditMode" class="cw-block-header"> + <span class="cw-sortable-handle"></span> <span v-if="!block.attributes.visible" class="cw-default-block-invisible-info"> <studip-icon shape="visibility-invisible" /> </span> @@ -14,8 +15,6 @@ :canEdit="canEdit" :deleteOnly="deleteOnly" @editBlock="displayFeature('Edit')" - @showFeedback="displayFeature('Feedback')" - @showComments="displayFeature('Comments')" @showInfo="displayFeature('Info')" @showExportOptions="displayFeature('ExportOptions')" @deleteBlock="displayDeleteDialog()" @@ -25,21 +24,6 @@ <slot name="content" /> </div> <div v-if="showFeatures" class="cw-block-features cw-block-features-default"> - <courseware-block-feedback - v-if="canEdit && showFeedback" - :block="block" - :canEdit="canEdit" - :isTeacher="isTeacher" - @close="displayFeature(false)" - /> - <courseware-block-comments - v-if="showComments" - :block="block" - :comments="currentComments" - @postComment="updateComments" - @close="displayFeature(false)" - ref="comments" - /> <courseware-block-export-options v-if="canEdit && showExportOptions" :block="block" @@ -62,6 +46,12 @@ </courseware-block-info> </div> </div> + <div v-if="discussView" class="cw-discuss-wrapper"> + <courseware-block-discussion + :block="block" + :canEdit="canEdit" + /> + </div> <studip-dialog v-if="showDeleteDialog" :title="textDeleteTitle" @@ -81,10 +71,13 @@ import CoursewareBlockExportOptions from './CoursewareBlockExportOptions.vue'; import CoursewareBlockFeedback from './CoursewareBlockFeedback.vue'; import CoursewareBlockInfo from './CoursewareBlockInfo.vue'; import CoursewareBlockActions from './CoursewareBlockActions.vue'; +import CoursewareTabs from './CoursewareTabs.vue'; +import CoursewareTab from './CoursewareTab.vue'; import StudipDialog from '../StudipDialog.vue'; import StudipIcon from '../StudipIcon.vue'; import { blockMixin } from './block-mixin.js'; import { mapActions, mapGetters } from 'vuex'; +import CoursewareBlockDiscussion from './CoursewareBlockDiscussion.vue'; export default { name: 'courseware-default-block', @@ -96,8 +89,11 @@ export default { CoursewareBlockFeedback, CoursewareBlockActions, CoursewareBlockInfo, + CoursewareTabs, + CoursewareTab, StudipDialog, StudipIcon, + CoursewareBlockDiscussion, }, props: { block: Object, @@ -111,13 +107,11 @@ export default { defaultGrade: { type: Boolean, default: true, - }, + } }, data() { return { showFeatures: false, - showFeedback: false, - showComments: false, showExportOptions: false, showEdit: false, showInfo: false, @@ -134,7 +128,6 @@ export default { blockTypes: 'blockTypes', userId: 'userId', viewMode: 'viewMode', - getComments: 'courseware-block-comments/related', containerById: 'courseware-containers/byId', }), showEditMode() { @@ -144,6 +137,9 @@ export default { } return show; }, + discussView() { + return this.viewMode === 'discuss'; + }, blocked() { return this.block?.relationships['edit-blocker'].data !== null; }, @@ -178,7 +174,6 @@ export default { deleteBlock: 'deleteBlockInContainer', lockObject: 'lockObject', unlockObject: 'unlockObject', - loadComments: 'courseware-block-comments/loadRelated', loadContainer: 'loadContainer', updateContainer: 'updateContainer', }), @@ -187,8 +182,6 @@ export default { return false; } this.showFeatures = false; - this.showFeedback = false; - this.showComments = false; this.showExportOptions = false; this.showEdit = false; this.showInfo = false; @@ -208,7 +201,6 @@ export default { return false; } - if (!this.preview) { this.showContent = false; } @@ -229,10 +221,6 @@ export default { this['show' + element] = true; this.showFeatures = true; } - - if (element === 'Comments') { - this.loadComments(); - } } }, async closeEdit() { @@ -285,26 +273,6 @@ export default { containerId: containerId, }); }, - - async loadComments() { - const parent = { - type: this.block.type, - id: this.block.id, - }; - await this.$store.dispatch('courseware-block-comments/loadRelated', { - parent, - relationship: 'comments', - options: { - include: 'user', - }, - }); - - this.currentComments = await this.getComments({ parent, relationship: 'comments' }); - }, - async updateComments() { - await this.loadComments(); - this.$refs.comments.$refs.comments.scrollTo(0, this.$refs.comments.$refs.comments.scrollHeight); - }, }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareDefaultBlockElements.vue b/resources/vue/components/courseware/CoursewareDefaultBlockElements.vue index c798366cd7d..17d8834ee1e 100755 --- a/resources/vue/components/courseware/CoursewareDefaultBlockElements.vue +++ b/resources/vue/components/courseware/CoursewareDefaultBlockElements.vue @@ -4,8 +4,6 @@ :block="block" :canEdit="canEdit" @editBlock="editBlock" - @showFeedback="showFeedback" - @showComments="showComments" @showExportOptions="showExportOptions" /> <courseware-block-feedback v-if="canEdit" :block="block" :canEdit="canEdit" :isTeacher="isTeacher" /> @@ -43,8 +41,6 @@ export default { editBlock() { this.$emit('editBlock'); }, - showFeedback() {}, - showComments() {}, showExportOptions() {}, }, }; diff --git a/resources/vue/components/courseware/CoursewareDefaultContainer.vue b/resources/vue/components/courseware/CoursewareDefaultContainer.vue index 53acc421c2c..edc46207e01 100755 --- a/resources/vue/components/courseware/CoursewareDefaultContainer.vue +++ b/resources/vue/components/courseware/CoursewareDefaultContainer.vue @@ -11,6 +11,7 @@ :container="container" @editContainer="displayEditDialog" @deleteContainer="displayDeleteDialog" + @sortBlocks="sortBlocks" /> </header> <div class="cw-block-wrapper" :class="{ 'cw-block-wrapper-active': showEditMode }"> @@ -103,6 +104,7 @@ export default { deleteContainer: 'deleteContainer', lockObject: 'lockObject', unlockObject: 'unlockObject', + companionInfo: 'companionInfo', }), async displayEditDialog() { if (this.blockedByAnotherUser) { @@ -152,6 +154,25 @@ export default { } this.showDeleteDialog = false; }, + async sortBlocks() { + if (this.blockedByAnotherUser) { + this.companionInfo({ info: this.$gettext('Dieser Abschnitt wird bereits bearbeitet.') }); + + return false; + } + try { + await this.lockObject({ id: this.container.id, type: 'courseware-containers' }); + } catch(error) { + if (error.status === 409) { + this.companionInfo({ info: this.$gettext('Dieser Abschnitt wird bereits bearbeitet.') }); + } else { + console.log(error); + } + + return false; + } + this.$emit('sortBlocks'); + } }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareHeadlineBlock.vue b/resources/vue/components/courseware/CoursewareHeadlineBlock.vue index 349ac9545c2..ab203ed62cc 100755 --- a/resources/vue/components/courseware/CoursewareHeadlineBlock.vue +++ b/resources/vue/components/courseware/CoursewareHeadlineBlock.vue @@ -69,7 +69,7 @@ <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <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> @@ -86,7 +86,7 @@ <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <translate>Es steht keine Auswahl zur Verfügung.</translate> </template> <template #selected-option="option"> <studip-icon :shape="option.label"/> <span class="vs__option-with-icon">{{option.label}}</span> @@ -110,7 +110,7 @@ <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <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> @@ -141,7 +141,7 @@ <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <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> diff --git a/resources/vue/components/courseware/CoursewareImageMapBlock.vue b/resources/vue/components/courseware/CoursewareImageMapBlock.vue index 082e2ac9ef3..d79443fb244 100755 --- a/resources/vue/components/courseware/CoursewareImageMapBlock.vue +++ b/resources/vue/components/courseware/CoursewareImageMapBlock.vue @@ -72,7 +72,7 @@ <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <translate>Es steht keine Auswahl zur Verfügung.</translate> </template> <template #selected-option="{name, rgba}"> <span class="vs__option-color" :style="{'background-color': rgba}"></span><span>{{name}}</span> diff --git a/resources/vue/components/courseware/CoursewareKeyPointBlock.vue b/resources/vue/components/courseware/CoursewareKeyPointBlock.vue index 7c57f2a6e5c..d8474fb99a3 100755 --- a/resources/vue/components/courseware/CoursewareKeyPointBlock.vue +++ b/resources/vue/components/courseware/CoursewareKeyPointBlock.vue @@ -41,7 +41,7 @@ <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <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> @@ -59,7 +59,7 @@ <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <translate>Es steht keine Auswahl zur Verfügung.</translate> </template> <template #selected-option="option"> <studip-icon :shape="option.label"/> <span class="vs__option-with-icon">{{option.label}}</span> diff --git a/resources/vue/components/courseware/CoursewareListContainer.vue b/resources/vue/components/courseware/CoursewareListContainer.vue index ed60f824e74..8e8506aedc1 100755 --- a/resources/vue/components/courseware/CoursewareListContainer.vue +++ b/resources/vue/components/courseware/CoursewareListContainer.vue @@ -5,14 +5,37 @@ :canEdit="canEdit" :isTeacher="isTeacher" @storeContainer="storeContainer" + @sortBlocks="enableSort" > <template v-slot:containerContent> - <ul class="cw-container-list-block-list"> + <ul v-if="!sortMode" class="cw-container-list-block-list"> <li v-for="block in blocks" :key="block.id" class="cw-block-item"> <component :is="component(block)" :block="block" :canEdit="canEdit" :isTeacher="isTeacher" /> </li> - <li v-if="showEditMode && canEdit"><courseware-block-adder-area :container="container" :section="0" /></li> + <li v-if="showEditMode && canEdit && canAddElements"><courseware-block-adder-area :container="container" :section="0" /></li> </ul> + <draggable + v-if="sortMode && canEdit" + class="cw-container-list-block-list cw-container-list-sort-mode" + tag="ul" + v-model="blockList" + v-bind="dragOptions" + handle=".cw-sortable-handle" + @start="isDragging = true" + @end="isDragging = false" + > + <transition-group type="transition" name="flip-blocks"> + <li v-for="block in blockList" :key="block.id" class="cw-block-item cw-block-item-sortable"> + <component :is="component(block)" :block="block" :canEdit="canEdit" :isTeacher="isTeacher" /> + </li> + </transition-group> + + </draggable> + <div v-if="sortMode && canEdit"> + <button class="button accept" @click="storeSort"><translate>Sortierung speichern</translate></button> + <button class="button cancel" @click="resetSort"><translate>Sortieren abbrechen</translate></button> + </div> + </template> </courseware-default-container> </template> @@ -20,19 +43,33 @@ <script> import ContainerComponents from './container-components.js'; import containerMixin from '../../mixins/courseware/container.js'; -import { mapGetters } from 'vuex'; +import draggable from 'vuedraggable'; +import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-list-container', mixins: [containerMixin], - components: ContainerComponents, + components: Object.assign(ContainerComponents, { + draggable + }), props: { container: Object, canEdit: Boolean, isTeacher: Boolean, + canAddElements: Boolean, }, data() { - return {}; + return { + sortMode: false, + isDragging: false, + dragOptions: { + animation: 0, + group: "description", + disabled: false, + ghostClass: "block-ghost" + }, + blockList: [], + }; }, computed: { ...mapGetters({ @@ -50,8 +87,37 @@ export default { }, }, methods: { + ...mapActions({ + updateContainer: 'updateContainer', + lockObject: 'lockObject', + unlockObject: 'unlockObject', + }), storeContainer(data) { }, + initCurrentData() { + this.blockList = this.blocks; + }, + enableSort() { + this.initCurrentData(); + this.sortMode = true; + }, + async storeSort() { + this.sortMode = false; + + let currentContainer = this.container; + currentContainer.attributes.payload.sections[0].blocks = this.blockList.map(block => {return block.id}); + await this.updateContainer({ + container: currentContainer, + structuralElementId: currentContainer.relationships['structural-element'].data.id, + }); + await this.unlockObject({ id: this.container.id, type: 'courseware-containers' }); + this.initCurrentData(); + }, + async resetSort() { + await this.unlockObject({ id: this.container.id, type: 'courseware-containers' }); + this.sortMode = false; + this.blockList = this.blocks; + }, component(block) { if (block.attributes["block-type"] !== undefined) { return 'courseware-' + block.attributes["block-type"] + '-block'; @@ -59,6 +125,8 @@ export default { return null; }, }, - mounted() {}, + mounted() { + this.initCurrentData(); + }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareManagerTaskDistributor.vue b/resources/vue/components/courseware/CoursewareManagerTaskDistributor.vue new file mode 100755 index 00000000000..839bd7b3c63 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareManagerTaskDistributor.vue @@ -0,0 +1,316 @@ +<template> + <div class="cw-manager-task-distributor"> + <form class="default" @submit.prevent=""> + <fieldset> + <legend><translate>Aufgabe</translate></legend> + <label> + <translate>Aufgabentitel</translate> + <input type="text" v-model="taskTitle" /> + </label> + <label> + <translate>Aufgabenvorlage</translate> + <select v-model="selectedElementId"> + <option value="" disabled> + <translate>wählen Sie eine Vorlage aus</translate> + </option> + <option v-for="template in taskTemplates" :key="template.id" :value="template.id"> + {{ template.attributes.title }} + </option> + </select> + </label> + <label> + <translate>Abgabefrist</translate> + <input type="date" v-model="submissionDate" /> + </label> + <label> + <translate>Inhalte ergänzen</translate> + <select class="size-s" v-model="solverMayAddBlocks"> + <option value="true"><translate>ja</translate></option> + <option value="false"><translate>nein</translate></option> + </select> + </label> + <label> + <translate>Type</translate> + <select v-model="taskSolverType"> + <option value="autor"><translate>für Studierende</translate></option> + <option value="group"><translate>für Gruppen</translate></option> + </select> + </label> + </fieldset> + <fieldset v-show="taskSolverType === 'autor'" class="cw-manager-task-distributor-task-solvers"> + <legend><translate>Studierende</translate></legend> + <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><translate>Name</translate></th> + <th><translate>Aufgabe zuweisen</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="user in autor_members" :key="user.user_id"> + <td>{{ user.formattedname }}</td> + <td><input type="checkbox" v-model="selectedAutors" :value="user.user_id" /></td> + </tr> + </tbody> + </table> + </fieldset> + <fieldset v-show="taskSolverType === 'group'" class="cw-manager-task-distributor-task-solvers"> + <legend><translate>Gruppen</translate></legend> + <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><translate>Gruppenname</translate></th> + <th><translate>Aufgabe zuweisen</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="group in groups" :key="group.id"> + <td>{{ group.name }}</td> + <td><input type="checkbox" v-model="selectedGroups" :value="group.id" /></td> + </tr> + </tbody> + </table> + </fieldset> + <footer> + <button class="button" name="create_task" :disabled="!targetSelected" @click="createTask"> + <translate>Aufgabe verteilen</translate> + </button> + </footer> + </form> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; + +export default { + name: 'courseware-manager-task-distributor', + components: { + CoursewareCompanionBox, + }, + data() { + return { + ownCoursewareInstance: null, + ownCoursewareElements: [], + taskSolverType: 'autor', + selectedElementId: '', + selectedAutors: [], + selectedGroups: [], + taskTitle: '', + submissionDate: '', + solverMayAddBlocks: true, + }; + }, + computed: { + ...mapGetters({ + context: 'context', + userId: 'userId', + structuralElementById: 'courseware-structural-elements/byId', + relatedCourseMemberships: 'course-memberships/related', + relatedCourseStatusGroups: 'status-groups/related', + relatedUser: 'users/related', + filingData: 'filingData', + }), + 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; + }, + ownCoursewareRootId() { + if (this.ownCoursewareInstance !== null) { + return this.ownCoursewareInstance.relationships.root.data.id; + } else { + return ''; + } + }, + ownCoursewareRoot() { + if (this.ownCoursewareRootId !== '') { + return this.structuralElementById({ id: this.ownCoursewareRootId }); + } else { + return null; + } + }, + taskTemplates() { + let templates = this.ownCoursewareElements.filter((elem) => { + return elem.attributes.purpose === 'template'; + }); + + return templates; + }, + targetSelected() { + return this.filingData.itemType === 'element'; + }, + }, + methods: { + ...mapActions({ + loadCourseMemberships: 'course-memberships/loadRelated', + loadCourseStatusGroups: 'status-groups/loadRelated', + loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure', + loadStructuralElementById: 'courseware-structural-elements/loadById', + loadStructuralElement: 'loadStructuralElement', + createTaskGroup: 'createTaskGroup', + companionWarning: 'companionWarning', + companionSuccess: 'companionSuccess', + }), + async loadOwnCourseware() { + this.ownCoursewareInstance = await this.loadRemoteCoursewareStructure({ + rangeId: this.userId, + rangeType: 'users', + }); + await this.loadStructuralElementById({ id: this.ownCoursewareRootId, options: { include: 'children' } }); + let children = this.ownCoursewareRoot.relationships.children.data; + for (let i = 0; i < children.length; i++) { + this.ownCoursewareElements.push(this.structuralElementById({ id: children[i].id })); + } + }, + async createTask() { + if (!this.targetSelected) { + return; + } + + if (this.taskTitle === '') { + this.companionWarning({ + info: this.$gettext('Bitte wählen Sie einen Aufgabentitel aus.'), + }); + + return false; + } + if (this.selectedElementId.trim() === '') { + this.companionWarning({ + info: this.$gettext('Bitte wählen Sie eine Aufgabenvorlage aus.'), + }); + + return false; + } + if (this.submissionDate === '') { + this.companionWarning({ + info: this.$gettext('Bitte wählen Sie eine Abgabefrist aus.'), + }); + + return false; + } + if (!['autor', 'group'].includes(this.taskSolverType)) { + this.companionWarning({ + info: this.$gettext('Bitte wählen Sie aus, an wen die Aufgabe verteilt werden sollen.'), + }); + + return false; + } + if (this.taskSolverType === 'autor') { + if (this.selectedAutors.length === 0) { + this.companionWarning({ + info: this.$gettext('Bitte wählen Sie mindestens einen Studierenden aus.'), + }); + return false; + } + } + if (this.taskSolverType === 'group') { + if (this.selectedGroups.length === 0) { + this.companionWarning({ + info: this.$gettext('Bitte wählen Sie mindestens eine Gruppe aus.'), + }); + return 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.filingData.parentItem.id, + type: 'courseware-structural-elements', + }, + }, + 'task-template': { + data: { + id: this.selectedElementId, + 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.'), + }); + }, + }, + mounted() { + const parent = { type: 'courses', id: this.context.id }; + this.loadCourseMemberships({ parent, relationship: 'memberships', options: { include: 'user' } }); + this.loadCourseStatusGroups({ parent, relationship: 'status-groups' }); + this.loadOwnCourseware(); + }, +}; +</script> diff --git a/resources/vue/components/courseware/CoursewareOblong.vue b/resources/vue/components/courseware/CoursewareOblong.vue index e3a905141a0..b1e601dea8a 100755 --- a/resources/vue/components/courseware/CoursewareOblong.vue +++ b/resources/vue/components/courseware/CoursewareOblong.vue @@ -4,7 +4,7 @@ <slot name="oblongValue"></slot> </div> <div class="cw-oblong-description"> - <studip-icon v-if="icon" :shape="icon" :size="24"></studip-icon>{{ name }} + <studip-icon v-if="icon" :shape="icon" role="info" :size="iconSize"></studip-icon>{{ name }} </div> </div> </template> @@ -29,6 +29,16 @@ export default { return 'small'; } }, + iconSize() { + switch (this.size) { + case 'large': + return 48; + case 'small': + return 24; + default: + return 24; + } + }, }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index b67001327e3..090f37a6149 100755 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -6,7 +6,7 @@ v-if="validContext" > <div class="cw-structural-element-content" v-if="structuralElement"> - <courseware-ribbon :canEdit="canEdit"> + <courseware-ribbon :canEdit="canEdit && canAddElements"> <template #buttons> <router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id"> <button class="cw-ribbon-button cw-ribbon-button-prev" :title="textRibbon.perv" /> @@ -26,7 +26,7 @@ > <span> <router-link :to="'/structural_element/' + ancestor.id"> - {{ ancestor.attributes.title }} + {{ ancestor.attributes.title || "–" }} </router-link> </span> </li> @@ -34,7 +34,7 @@ class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current" :title="structuralElement.attributes.title" > - <span>{{ structuralElement.attributes.title }}</span> + <span>{{ structuralElement.attributes.title || "–" }}</span> </li> </template> <template #breadcrumbFallback> @@ -57,38 +57,79 @@ @showExportOptions="menuAction('showExportOptions')" @oerCurrentElement="menuAction('oerCurrentElement')" @setBookmark="menuAction('setBookmark')" + @sortContainers="menuAction('sortContainers')" + @pdfExport="menuAction('pdfExport')" /> </template> </courseware-ribbon> <div - v-if="canVisit" + v-if="canVisit && !sortMode" class="cw-container-wrapper" - :class="{ 'cw-container-wrapper-consume': consumeMode }" + :class="{ + 'cw-container-wrapper-consume': consumeMode, + 'cw-container-wrapper-discuss': discussView, + }" > <div v-if="structuralElementLoaded" class="cw-companion-box-wrapper"> <courseware-empty-element-box - v-if=" - (empty && !isRoot && canEdit) || - (empty && !canEdit) || - (!noContainers && empty && isRoot && canEdit) - " + v-if="showEmptyElementBox" :canEdit="canEdit" :noContainers="noContainers" /> <courseware-wellcome-screen v-if="noContainers && isRoot && canEdit" /> </div> + <courseware-structural-element-discussion + v-if="!noContainers && discussView" + :structuralElement="structuralElement" + :canEdit="canEdit" + /> <component v-for="container in containers" :key="container.id" :is="containerComponent(container)" :container="container" :canEdit="canEdit" - :isTeacher="isTeacher" + :canAddElements="canAddElements" + :isTeacher="userIsTeacher" class="cw-container-item" /> </div> - <div v-else class="cw-container-wrapper" :class="{ 'cw-container-wrapper-consume': consumeMode }"> + <div v-if="canVisit && canEdit && sortMode" class="cw-container-wrapper-sort-mode"> + <draggable + class="cw-structural-element-list-sort-mode" + tag="ul" + v-model="containerList" + v-bind="dragOptions" + handle=".cw-sortable-handle" + @start="isDragging = true" + @end="isDragging = false" + > + <transition-group type="transition" name="flip-containers"> + <li + v-for="container in containerList" + :key="container.id" + class="cw-container-item-sortable" + > + <span class="cw-sortable-handle"></span> + <span>{{ container.attributes.title }} ({{ container.attributes.width }})</span> + </li> + </transition-group> + </draggable> + <div class="cw-container-sort-buttons"> + <button class="button accept" @click="storeSort"> + <translate>Sortierung speichern</translate> + </button> + <button class="button cancel" @click="resetSort"> + <translate>Sortieren abbrechen</translate> + </button> + </div> + </div> + <div + v-if="!canVisit" + class="cw-container-wrapper" + :class="{ 'cw-container-wrapper-consume': consumeMode }" + > <div v-if="structuralElementLoaded" class="cw-companion-box-wrapper"> <courseware-companion-box mood="sad" @@ -147,7 +188,7 @@ /></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <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 @@ -163,7 +204,7 @@ <translate>Zweck</translate> <select v-model="currentElement.attributes.purpose"> <option value="content"><translate>Inhalt</translate></option> - <option value="template"><translate>Vorlage</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> @@ -287,7 +328,10 @@ </label> <label> <translate>Name der neuen Seite</translate><br /> - <input v-model="newChapterName" type="text" /> + <input v-model="newChapterName" type="text" required /> + <div class="invalid_message" :style="{ display: errorEmptyChapterName ? 'block' : 'none' }"> + <translate>Der Name der neuen Seite darf nicht leer sein.</translate> + </div> </label> </form> </template> @@ -415,7 +459,7 @@ <p>{{ currentLicenseName }}</p> </label> <label> - <translate>Sie können diese Daten unter "Seite bearbeiten" verändern</translate>. + <translate>Sie können diese Daten unter "Seite bearbeiten" verändern.</translate> </label> </fieldset> <fieldset> @@ -452,6 +496,7 @@ import ContainerComponents from './container-components.js'; import CoursewarePluginComponents from './plugin-components.js'; import CoursewareStructuralElementPermissions from './CoursewareStructuralElementPermissions.vue'; +import CoursewareStructuralElementDiscussion from './CoursewareStructuralElementDiscussion.vue'; import CoursewareAccordionContainer from './CoursewareAccordionContainer.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import CoursewareWellcomeScreen from './CoursewareWellcomeScreen.vue'; @@ -465,11 +510,13 @@ import CoursewareTab from './CoursewareTab.vue'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import IsoDate from './IsoDate.vue'; import StudipDialog from '../StudipDialog.vue'; +import draggable from 'vuedraggable'; import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-structural-element', components: { + CoursewareStructuralElementDiscussion, CoursewareStructuralElementPermissions, CoursewareRibbon, CoursewareListContainer, @@ -483,6 +530,7 @@ export default { CoursewareTab, IsoDate, StudipDialog, + draggable, }, props: ['canVisit', 'orderedStructuralElements', 'structuralElement'], @@ -526,16 +574,27 @@ export default { exportRunning: false, exportChildren: false, oerChildren: true, + containerList: [], + isDragging: false, + dragOptions: { + animation: 0, + group: 'description', + disabled: false, + ghostClass: 'container-ghost', + }, + errorEmptyChapterName: false, }; }, computed: { ...mapGetters({ courseware: 'courseware', + context: 'context', consumeMode: 'consumeMode', containerById: 'courseware-containers/byId', relatedContainers: 'courseware-containers/related', relatedStructuralElements: 'courseware-structural-elements/related', + relatedTaskGroups: 'courseware-task-groups/related', relatedUsers: 'users/related', structuralElementById: 'courseware-structural-elements/byId', userIsTeacher: 'userIsTeacher', @@ -552,6 +611,9 @@ export default { exportState: 'exportState', exportProgress: 'exportProgress', userId: 'userId', + sortMode: 'structuralElementSortMode', + viewMode: 'viewMode', + taskById: 'courseware-tasks/byId', }), currentId() { @@ -624,7 +686,7 @@ export default { if (!parentId) { return null; } - const element = this.structuralElementById({id: parentId}); + const element = this.structuralElementById({ id: parentId }); if (!element) { console.error(`CoursewareStructuralElement#ancestors: Could not find parent by ID: "${parentId}".`); } @@ -636,11 +698,11 @@ export default { const parent = finder(node); if (parent) { yield parent; - yield *visitAncestors(parent); + yield* visitAncestors(parent); } }; - return [...visitAncestors(this.structuralElement)].reverse() + return [...visitAncestors(this.structuralElement)].reverse(); }, prevElement() { const currentIndex = this.orderedStructuralElements.indexOf(this.structuralElement.id); @@ -697,10 +759,6 @@ export default { return this.structuralElement.attributes['can-edit']; }, - isTeacher() { - return this.userIsTeacher; - }, - isRoot() { return this.structuralElement.relationships.parent.data === null; }, @@ -724,8 +782,8 @@ export default { }, menuItems() { let menu = [ - { id: 3, label: this.$gettext('Informationen anzeigen'), icon: 'info', emit: 'showInfo' }, - { id: 4, label: this.$gettext('Lesezeichen setzen'), icon: 'star', emit: 'setBookmark' }, + { id: 4, label: this.$gettext('Informationen anzeigen'), icon: 'info', emit: 'showInfo' }, + { id: 5, label: this.$gettext('Lesezeichen setzen'), icon: 'star', emit: 'setBookmark' }, ]; if (this.canEdit) { menu.push({ @@ -734,20 +792,38 @@ export default { icon: 'edit', emit: 'editCurrentElement', }); - menu.push({ id: 2, label: this.$gettext('Seite hinzufügen'), icon: 'add', emit: 'addElement' }); menu.push({ - id: 5, + id: 2, + label: this.$gettext('Abschnitte sortieren'), + icon: 'arr_1sort', + emit: 'sortContainers', + }); + + menu.push({ id: 3, label: this.$gettext('Seite hinzufügen'), icon: 'add', emit: 'addElement' }); + } + if ((this.userIsTeacher && this.canEdit) || this.context.type === 'users') { + menu.push({ + id: 6, label: this.$gettext('Seite exportieren'), icon: 'export', emit: 'showExportOptions', }); } - if (this.canEdit && this.oerEnabled) { - menu.push({ id: 6, label: this.textOer.title, icon: 'oer-campus', emit: 'oerCurrentElement' }); - } - if (!this.isRoot && this.canEdit) { + if ((this.userIsTeacher || this.canEdit) && this.canVisit) { menu.push({ id: 7, + type: 'link', + label: this.$gettext('Seite als pdf-Dokument exportieren'), + icon: 'file-pdf', + url: this.pdfExportURL, + }); + } + if (this.canEdit && this.oerEnabled && this.userIsTeacher) { + menu.push({ id: 8, label: this.textOer.title, icon: 'oer-campus', emit: 'oerCurrentElement' }); + } + if (!this.isRoot && this.canEdit && !this.isTask) { + menu.push({ + id: 9, label: this.$gettext('Seite löschen'), icon: 'trash', emit: 'deleteCurrentElement', @@ -928,6 +1004,56 @@ export default { blockedByAnotherUser() { return this.blocked && this.userId !== this.blockerId; }, + discussView() { + return this.viewMode === 'discuss'; + }, + pdfExportURL() { + if (this.context.type === 'users') { + return STUDIP.URLHelper.getURL( + 'dispatch.php/contents/courseware/pdf_export/' + this.structuralElement.id + ); + } + if (this.context.type === 'courses') { + return STUDIP.URLHelper.getURL( + 'dispatch.php/course/courseware/pdf_export/' + this.structuralElement.id + ); + } + + return ''; + }, + isTask() { + return this.structuralElement?.relationships.task.data !== null; + }, + task() { + if (!this.isTask) { + return null; + } + + return this.taskById({ id: this.structuralElement.relationships.task.data.id }); + }, + canAddElements() { + if (!this.isTask) { + return true; + } + + // still loading + if (!this.task) { + return false; + } + + const taskGroup = this.relatedTaskGroups({ parent: this.task, relationship: 'task-group' }); + + return taskGroup?.attributes['solver-may-add-blocks']; + }, + showEmptyElementBox() { + if (!this.empty) { + return false; + } + + return ( + (!this.isRoot && this.canEdit) || !this.canEdit || (!this.noContainers && this.isRoot && this.canEdit) + ); + }, }, methods: { @@ -948,6 +1074,10 @@ export default { showElementInfoDialog: 'showElementInfoDialog', showElementDeleteDialog: 'showElementDeleteDialog', showElementOerDialog: 'showElementOerDialog', + updateContainer: 'updateContainer', + setStructuralElementSortMode: 'setStructuralElementSortMode', + sortContainersInStructualElements: 'sortContainersInStructualElements', + loadTask: 'loadTask', }), initCurrent() { @@ -964,7 +1094,7 @@ export default { } try { await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); - } catch(error) { + } catch (error) { if (error.status === 409) { this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); } else { @@ -978,6 +1108,7 @@ export default { case 'addElement': this.newChapterName = ''; this.newChapterParent = 'descendant'; + this.errorEmptyChapterName = false; this.showElementAddDialog(true); break; case 'deleteCurrentElement': @@ -996,6 +1127,24 @@ export default { case 'setBookmark': this.setBookmark(); break; + case 'sortContainers': + 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.enableSortContainers(); } }, async closeEditDialog() { @@ -1057,6 +1206,25 @@ export default { this.initCurrent(); }, + enableSortContainers() { + this.setStructuralElementSortMode(true); + }, + + storeSort() { + this.setStructuralElementSortMode(false); + + this.sortContainersInStructualElements({ + structuralElement: this.structuralElement, + containers: this.containerList, + }); + this.$emit('select', this.currentId); + }, + + resetSort() { + this.setStructuralElementSortMode(false); + this.containerList = this.containers; + }, + async exportCurrentElement(data) { if (this.exportRunning) { return; @@ -1088,10 +1256,14 @@ export default { parentId: this.structuralElement.relationships.parent.data.id, }); this.$router.push(parent_id); + this.companionInfo({ info: this.$gettext('Die Seite wurde gelöscht.') }); }, async createElement() { let title = this.newChapterName; // this is the title of the new element let parent_id = this.currentId; // new page is descandant as default + if (this.errorEmptyChapterName = title.trim() === '') { + return; + } if (this.newChapterParent === 'sibling') { parent_id = this.structuralElement.relationships.parent.data.id; } @@ -1108,6 +1280,7 @@ export default { info: this.$gettextInterpolate('Die Seite %{ pageTitle } wurde erfolgreich angelegt.', {pageTitle: newElement.attributes.title}) }); + this.newChapterName = ''; }, containerComponent(container) { return 'courseware-' + container.attributes['container-type'] + '-container'; @@ -1130,6 +1303,14 @@ export default { watch: { structuralElement() { this.initCurrent(); + if (this.isTask) { + this.loadTask({ + taskId: this.structuralElement.relationships.task.data.id, + }); + } + }, + containers() { + this.containerList = this.containers; }, }, diff --git a/resources/vue/components/courseware/CoursewareStructuralElementComments.vue b/resources/vue/components/courseware/CoursewareStructuralElementComments.vue new file mode 100755 index 00000000000..da6de348159 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareStructuralElementComments.vue @@ -0,0 +1,128 @@ +<template> + <section + class="cw-structural-element-comments" + :class="[emptyComments ? 'cw-structural-element-comments-empty' : '']" + > + <div class="cw-structural-element-comments-items" v-show="!emptyComments" ref="commentsRef"> + <courseware-talk-bubble + v-for="comment in comments" + :key="comment.id" + :payload="buildPayload(comment)" + /> + </div> + <div class="cw-structural-element-comment-create"> + <textarea v-model="createComment" :placeholder="placeHolder" spellcheck="true"></textarea> + <button class="button" @click="postComment"><translate>Senden</translate></button> + </div> + </section> +</template> + +<script> +import CoursewareTalkBubble from './CoursewareTalkBubble.vue'; +import { mapGetters } from 'vuex'; + +export default { + name: 'courseware-structural-element-comments', + components: { + CoursewareTalkBubble, + }, + props: { + structuralElement: Object, + }, + data() { + return { + createComment: '', + placeHolder: this.$gettext('Stellen Sie eine Frage oder kommentieren Sie...'), + }; + }, + computed: { + ...mapGetters({ + relatedUser: 'users/related', + userId: 'userId', + getComments: 'courseware-structural-element-comments/related', + }), + comments() { + const parent = { + type: this.structuralElement.type, + id: this.structuralElement.id, + }; + + return this.getComments({ parent, relationship: 'comments' }); + }, + emptyComments() { + if (this.comments === null || this.comments.length === 0) { + return true; + } + + return false; + } + }, + methods: { + async loadComments() { + const parent = { + type: this.structuralElement.type, + id: this.structuralElement.id, + }; + await this.$store.dispatch('courseware-structural-element-comments/loadRelated', { + parent, + relationship: 'comments', + options: { + include: 'user', + }, + }); + }, + async postComment() { + const data = { + attributes: { + comment: this.createComment + }, + relationships: { + 'structural-element': { + data: { + id: this.structuralElement.id, + type: this.structuralElement.type + } + } + } + }; + + await this.$store.dispatch('courseware-structural-element-comments/create', data); + this.loadComments(); + this.createComment = ''; + }, + buildPayload(comment) { + const commenter = this.relatedUser({ + parent: { id: comment.id, type: comment.type }, + relationship: 'user', + }); + + const payload = { + id: comment.id, + own: comment.relationships.user.data.id === this.userId, + content: comment.attributes.comment, + chdate: comment.attributes.chdate, + mkdate: comment.attributes.mkdate, + user_id: commenter.id, + user_name: commenter.attributes['formatted-name'], + user_avatar: commenter.meta.avatar.small, + }; + + return payload; + }, + }, + mounted() { + this.loadComments(); + }, + updated() { + let ref = this.$refs["commentsRef"]; + ref.scrollTop = ref.scrollHeight; + }, + watch: { + comments() { + if (this.comments && this.comments.length > 0) { + this.$emit('hasComments'); + } + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareStructuralElementDiscussion.vue b/resources/vue/components/courseware/CoursewareStructuralElementDiscussion.vue new file mode 100755 index 00000000000..fcd275c1b2f --- /dev/null +++ b/resources/vue/components/courseware/CoursewareStructuralElementDiscussion.vue @@ -0,0 +1,60 @@ +<template> + <div class="cw-structural-element-discussion"> + <courseware-collapsible-box + :title="text.comments" + :open="hasComments" + > + <courseware-structural-element-comments + :structuralElement="structuralElement" + @hasComments="hasComments = true" + /> + </courseware-collapsible-box> + + <courseware-collapsible-box + v-if="canEdit || userIsTeacher" + :title="text.feedback" + :open="hasFeedback" + > + <courseware-structural-element-feedback + :structuralElement="structuralElement" + :canEdit="canEdit" + @hasFeedback="hasFeedback = true" + /> + </courseware-collapsible-box> + </div> +</template> + +<script> +import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; +import CoursewareStructuralElementComments from './CoursewareStructuralElementComments.vue'; +import CoursewareStructuralElementFeedback from './CoursewareStructuralElementFeedback.vue'; +import { mapGetters } from 'vuex'; + +export default { + name: 'courseware-structural-element-discussion', + components: { + CoursewareCollapsibleBox, + CoursewareStructuralElementComments, + CoursewareStructuralElementFeedback, + }, + props: { + structuralElement: Object, + canEdit: Boolean + }, + data() { + return { + hasComments: false, + hasFeedback: false, + text: { + comments: this.$gettext('Kommentare zur Seite'), + feedback: this.$gettext('Feedback zur Seite') + } + } + }, + computed: { + ...mapGetters({ + userIsTeacher: 'userIsTeacher', + }), + } +} +</script> diff --git a/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue b/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue new file mode 100755 index 00000000000..cc079eada2d --- /dev/null +++ b/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue @@ -0,0 +1,130 @@ +<template> + <section + v-if="canEdit || userIsTeacher" + class="cw-structural-element-feedback" + :class="[emptyFeedback ? 'cw-structural-element-feedback-empty' : '']" + > + <div class="cw-structural-element-feedback-items" v-show="!emptyFeedback" ref="feedbacks"> + <courseware-talk-bubble + v-for="feedback in feedback" + :key="feedback.id" + :payload="buildPayload(feedback)" + /> + </div> + <courseware-companion-box + v-if="!userIsTeacher && feedback.length === 0" + :msgCompanion="$gettext('Es wurde noch kein Feedback abgegeben.')" + mood="pointing" + /> + <div v-if="userIsTeacher" class="cw-structural-element-feedback-create"> + <textarea v-model="feedbackText" :placeholder="placeHolder" spellcheck="true"></textarea> + <button class="button" @click="postFeedback"><translate>Senden</translate></button> + </div> + </section> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import CoursewareTalkBubble from './CoursewareTalkBubble.vue'; +import { mapGetters } from 'vuex'; + +export default { + name: 'courseware-structural-element-feedback', + components: { + CoursewareCompanionBox, + CoursewareTalkBubble, + }, + props: { + structuralElement: Object, + canEdit: Boolean, + }, + data() { + return { + feedbackText: '', + placeHolder: this.$gettext('Schreiben Sie ein Feedback...'), + }; + }, + computed: { + ...mapGetters({ + userId: 'userId', + getRelatedFeedback: 'courseware-structural-element-feedback/related', + getRelatedUser: 'users/related', + userIsTeacher: 'userIsTeacher', + }), + feedback() { + const parent = { + type: this.structuralElement.type, + id: this.structuralElement.id, + }; + + return this.getRelatedFeedback({ parent, relationship: 'feedback' }); + }, + emptyFeedback() { + if (this.feedback === null || this.feedback.length === 0) { + return true; + } + + return false; + } + }, + methods: { + buildPayload(feedback) { + const { id, type } = feedback; + const user = this.getRelatedUser({ parent: { id, type }, relationship: 'user' }); + + return { + own: user.id === this.userId, + content: feedback.attributes.feedback, + chdate: feedback.attributes.chdate, + mkdate: feedback.attributes.mkdate, + user_name: user?.attributes?.['formatted-name'] ?? '', + user_avatar: user?.meta?.avatar.small, + }; + }, + async loadFeedback() { + const parent = { + type: this.structuralElement.type, + id: this.structuralElement.id, + }; + await this.$store.dispatch('courseware-structural-element-feedback/loadRelated', { + parent, + relationship: 'feedback', + options: { + include: 'user', + }, + }); + }, + async postFeedback() { + const data = { + attributes: { + feedback: this.feedbackText, + }, + relationships: { + 'structural-element': { + data: { + id: this.structuralElement.id, + type: this.structuralElement.type + } + } + }, + }; + await this.$store.dispatch('courseware-structural-element-feedback/create', data, { root: true }); + this.feedbackText = ''; + this.loadFeedback(); + } + }, + async mounted() { + await this.loadFeedback(); + }, + updated() { + this.$refs.feedbacks.scrollTop = this.$refs.feedbacks.scrollHeight; + }, + watch: { + feedback() { + if (this.feedback && this.feedback.length > 0) { + this.$emit('hasFeedback'); + } + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareTableOfContentsBlock.vue b/resources/vue/components/courseware/CoursewareTableOfContentsBlock.vue index e35e041962f..852f2139ec2 100755 --- a/resources/vue/components/courseware/CoursewareTableOfContentsBlock.vue +++ b/resources/vue/components/courseware/CoursewareTableOfContentsBlock.vue @@ -45,9 +45,9 @@ <p>{{ child.attributes.payload.description }}</p> </div> <footer> - {{ countChildren }} + {{ countChildChildren(child) }} <translate - :translate-n="countChildren" + :translate-n="countChildChildren(child)" translate-plural="Seiten" > Seite @@ -118,22 +118,6 @@ export default { }, style() { return this.block?.attributes?.payload?.style; - }, - childSets() { - let childSets = []; - let childElements = this.childElements; - while (childElements.length > 0) { - let set = []; - for (let i = 0; i < 4; i++) { - let elem = childElements.shift(); - if (elem !== undefined) { - set.push(elem); - } - } - childSets.push(set); - } - - return childSets; } }, mounted() { @@ -171,6 +155,9 @@ export default { return {}; } }, + countChildChildren(child) { + return this.childrenById(child.id).length; + }, hasImage(child) { return child.relationships?.image?.data !== null; }, diff --git a/resources/vue/components/courseware/CoursewareTabsContainer.vue b/resources/vue/components/courseware/CoursewareTabsContainer.vue index f8502bd8542..c9bb646e7a5 100755 --- a/resources/vue/components/courseware/CoursewareTabsContainer.vue +++ b/resources/vue/components/courseware/CoursewareTabsContainer.vue @@ -6,9 +6,10 @@ :isTeacher="isTeacher" @storeContainer="storeContainer" @closeEdit="initCurrentData" + @sortBlocks="enableSort" > <template v-slot:containerContent> - <courseware-tabs> + <courseware-tabs v-if="!sortMode"> <courseware-tab v-for="(section, index) in currentSections" :key="index" @@ -26,12 +27,42 @@ :isTeacher="isTeacher" /> </li> - <li v-if="showEditMode"> + <li v-if="showEditMode && canAddElements"> <courseware-block-adder-area :container="container" :section="index" @updateContainerContent="updateContent"/> </li> </ul> </courseware-tab> </courseware-tabs> + <div v-if="sortMode && canEdit" class="cw-container-tabs-sort"> + <courseware-collapsible-box + v-for="(section, index) in currentSections" + :key="index" + :title="section.name" + :icon="section.icon" + :open="index === 0" + > + <draggable + class="cw-container-list-block-list cw-container-list-sort-mode" + :class="[section.blocks.length === 0 ? 'cw-container-list-sort-mode-empty' : '']" + tag="ul" + v-model="section.blocks" + v-bind="dragOptions" + handle=".cw-sortable-handle" + @start="isDragging = true" + @end="isDragging = false" + > + <transition-group type="transition" name="flip-blocks" tag="div"> + <li v-for="block in section.blocks" :key="block.id" class="cw-block-item cw-block-item-sortable"> + <component :is="component(block)" :block="block" :canEdit="canEdit" :isTeacher="isTeacher" /> + </li> + </transition-group> + </draggable> + </courseware-collapsible-box> + <div> + <button class="button accept" @click="storeSort"><translate>Sortierung speichern</translate></button> + <button class="button cancel" @click="resetSort"><translate>Sortieren abbrechen</translate></button> + </div> + </div> </template> <template v-slot:containerEditDialog> <form class="default cw-container-dialog-edit-form" @submit.prevent=""> @@ -47,7 +78,7 @@ <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span> </template> <template #no-options="{ search, searching, loading }"> - <translate>Es steht keine Auswahl zur Verfügung</translate>. + <translate>Es steht keine Auswahl zur Verfügung.</translate> </template> <template #selected-option="option"> <studip-icon :shape="option.label"/> <span class="vs__option-with-icon">{{option.label}}</span> @@ -76,6 +107,7 @@ import containerMixin from '../../mixins/courseware/container.js'; import contentIcons from './content-icons.js'; import CoursewareTabs from './CoursewareTabs.vue'; import CoursewareTab from './CoursewareTab.vue'; +import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; import StudipIcon from './../StudipIcon.vue'; import { mapGetters, mapActions } from 'vuex'; @@ -86,19 +118,29 @@ export default { components: Object.assign(ContainerComponents, { CoursewareTabs, CoursewareTab, + CoursewareCollapsibleBox, StudipIcon, }), props: { container: Object, canEdit: Boolean, isTeacher: Boolean, + canAddElements: Boolean, }, data() { return { currentContainer: null, currentSections: [], textDeleteSection: this.$gettext('Sektion entfernen'), - selectAttributes: {'ref': 'openIndicator', 'role': 'presentation', 'class': 'vs__open-indicator'} + selectAttributes: {'ref': 'openIndicator', 'role': 'presentation', 'class': 'vs__open-indicator'}, + sortMode: false, + isDragging: false, + dragOptions: { + animation: 0, + group: "description", + disabled: false, + ghostClass: "block-ghost" + }, }; }, computed: { @@ -125,6 +167,7 @@ export default { methods: { ...mapActions({ updateContainer: 'updateContainer', + lockObject: 'lockObject', unlockObject: 'unlockObject', }), initCurrentData() { @@ -174,6 +217,17 @@ export default { await this.unlockObject({ id: this.container.id, type: 'courseware-containers' }); this.initCurrentData(); }, + enableSort() { + this.sortMode = true; + }, + async storeSort() { + this.sortMode = false; + this.storeContainer(); + }, + async resetSort() { + await this.unlockObject({ id: this.currentContainer.id, type: 'courseware-containers' }); + this.sortMode = false; + }, component(block) { if (block.attributes) { return 'courseware-' + block.attributes["block-type"] + '-block'; diff --git a/resources/vue/components/courseware/CoursewareTreeItem.vue b/resources/vue/components/courseware/CoursewareTreeItem.vue index bf7f522d5b6..05047eec468 100755 --- a/resources/vue/components/courseware/CoursewareTreeItem.vue +++ b/resources/vue/components/courseware/CoursewareTreeItem.vue @@ -1,15 +1,43 @@ <template> <li> - <div :class="{ 'cw-tree-item-is-root': isRoot, 'cw-tree-item-first-level': isFirstLevel }"> + <div + :class="[ + isRoot ? 'cw-tree-item-is-root' : '', + isFirstLevel ? 'cw-tree-item-first-level' : '', + hasPurposeClass ? 'cw-tree-item-' + purposeClass : '', + ]" + > <router-link :to="'/structural_element/' + element.id" class="cw-tree-item-link" :class="{ 'cw-tree-item-link-current': isCurrent }" > - {{ element.attributes.title }} + {{ element.attributes.title || "–" }} + <span v-if="task">| {{ solverName }}</span> + <span + v-if="hasReleaseOrWithdrawDate" + class="cw-tree-item-flag-date" + :title="$gettext('Diese Seite hat eine zeitlich beschränkte Sichtbarkeit')" + ></span> + <span + v-if="hasWriteApproval" + class="cw-tree-item-flag-write" + :title="$gettext('Diese Seite kann von Teilnehmenden bearbeitet werden')" + ></span> + <span + v-if="hasNoReadApproval" + class="cw-tree-item-flag-cant-read" + :title="$gettext('Diese Seite kann von Teilnehmenden nicht gesehen werden')" + ></span> </router-link> </div> - <ul v-if="hasChildren" :class="{ 'cw-tree-chapter-list': isRoot }"> + <ul + v-if="hasChildren" + :class="{ + 'cw-tree-chapter-list': isRoot, + 'cw-tree-subchapter-list': isFirstLevel, + }" + > <courseware-tree-item v-for="child in children" :key="child.id" @@ -23,7 +51,7 @@ </template> <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; export default { name: 'courseware-tree-item', @@ -44,6 +72,10 @@ export default { ...mapGetters({ childrenById: 'courseware-structure/children', structuralElementById: 'courseware-structural-elements/byId', + context: 'context', + taskById: 'courseware-tasks/byId', + userById: 'users/byId', + groupById: 'status-groups/byId', }), children() { if (!this.element) { @@ -66,6 +98,88 @@ export default { isCurrent() { return this.element.id === this.currentElement?.id; }, + hasReleaseOrWithdrawDate() { + return ( + this.element.attributes['release-date'] !== null || this.element.attributes['withdraw-date'] !== null + ); + }, + hasWriteApproval() { + const writeApproval = this.element.attributes['write-approval']; + + if (Object.keys(writeApproval).length === 0) { + return false; + } + return writeApproval.all || writeApproval.groups.length > 0 || writeApproval.users.length > 0; + }, + hasNoReadApproval() { + const readApproval = this.element.attributes['read-approval']; + + if (Object.keys(readApproval).length === 0 || this.hasWriteApproval) { + return false; + } + return !readApproval.all && readApproval.groups.length === 0 && readApproval.users.length === 0; + }, + hasPurposeClass() { + return this.purposeClass !== ''; + }, + purposeClass() { + if ( + (this.isFirstLevel && this.context.type === 'users') || + (this.context.type === 'courses' && this.element.attributes.purpose === 'task') + ) { + return this.element.attributes.purpose; + } + return ''; + }, + task() { + if (this.element.relationships.task.data) { + return this.taskById({ + id: this.element.relationships.task.data.id, + }); + } + + return null; + }, + taskProgress() { + return this.task ? this.task.attributes.progress + '%' : ''; + }, + solver() { + if (this.task) { + const solver = this.task.relationships.solver.data; + if (solver.type === 'users') { + return this.userById({ id: solver.id }); + } + if (solver.type === 'status-groups') { + return this.groupById({ id: solver.id }); + } + } + + return null; + }, + solverName() { + if (this.solver) { + if (this.solver.type === 'users') { + return this.solver.attributes['formatted-name']; + } + if (this.solver.type === 'status-groups') { + return this.solver.attributes.name; + } + } + + return ''; + }, + }, + methods: { + ...mapActions({ + loadTask: 'loadTask', + }), + }, + mounted() { + if (this.element.relationships.task.data) { + this.loadTask({ + taskId: this.element.relationships.task.data.id, + }); + } }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareViewWidget.vue b/resources/vue/components/courseware/CoursewareViewWidget.vue index ff18ff52b4a..fd3ee9ff7b5 100755 --- a/resources/vue/components/courseware/CoursewareViewWidget.vue +++ b/resources/vue/components/courseware/CoursewareViewWidget.vue @@ -1,21 +1,53 @@ <template> <ul class="widget-list widget-links sidebar-views cw-view-widget"> - <li :class="{ active: readView }" @click="setReadView"><translate>Lesen</translate></li> - <li :class="{ active: editView }" @click="setEditView"><translate>Bearbeiten</translate></li> + <li + :class="{ active: readView }" + @click="setReadView" + > + <translate>Lesen</translate> + </li> + <li + v-if="canEdit" + :class="{ active: editView }" + @click="setEditView" + > + <translate>Bearbeiten</translate> + </li> + <li + v-if="context.type === 'courses' && canVisit" + :class="{ active: discussView }" + @click="setDiscussView" + > + <translate>Diskutieren</translate> + </li> </ul> </template> <script> -import { mapActions } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-view-widget', + props: ['structuralElement', 'canVisit'], computed: { + ...mapGetters({ + viewMode: 'viewMode', + context: 'context', + }), readView() { - return this.$store.getters.viewMode === 'read'; + return this.viewMode === 'read'; }, editView() { - return this.$store.getters.viewMode === 'edit'; + return this.viewMode === 'edit'; + }, + discussView() { + return this.viewMode === 'discuss'; + }, + canEdit() { + if (!this.structuralElement) { + return false; + } + return this.structuralElement.attributes['can-edit']; }, }, methods: { @@ -29,6 +61,9 @@ export default { setEditView() { this.$store.dispatch('coursewareViewMode', 'edit'); }, + setDiscussView() { + this.$store.dispatch('coursewareViewMode', 'discuss'); + }, }, }; </script> diff --git a/resources/vue/components/courseware/DashboardApp.vue b/resources/vue/components/courseware/DashboardApp.vue index 4338be2f192..34290c55863 100755 --- a/resources/vue/components/courseware/DashboardApp.vue +++ b/resources/vue/components/courseware/DashboardApp.vue @@ -1,11 +1,36 @@ <template> - <courseware-course-dashboard></courseware-course-dashboard> + <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 }, + 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 7209dedffad..427e363a1d7 100755 --- a/resources/vue/components/courseware/IndexApp.vue +++ b/resources/vue/components/courseware/IndexApp.vue @@ -7,10 +7,10 @@ @select="selectStructuralElement" ></courseware-structural-element> <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions"> - <courseware-action-widget :structural-element="selected"></courseware-action-widget> + <courseware-action-widget :structural-element="selected" :canVisit="canVisit"></courseware-action-widget> </MountingPortal> <MountingPortal mountTo="#courseware-view-widget" name="sidebar-views"> - <courseware-view-widget></courseware-view-widget> + <courseware-view-widget :structural-element="selected" :canVisit="canVisit"></courseware-view-widget> </MountingPortal> </div> <studip-progress-indicator diff --git a/resources/vue/courseware-admin-app.js b/resources/vue/courseware-admin-app.js new file mode 100755 index 00000000000..2ca74549a14 --- /dev/null +++ b/resources/vue/courseware-admin-app.js @@ -0,0 +1,42 @@ +import AdminApp from './components/courseware/AdminApp.vue'; +import { mapResourceModules } from '@elan-ev/reststate-vuex'; +import Vuex from 'vuex'; +import CoursewareAdminModule from './store/courseware/courseware-admin.module'; +import axios from 'axios'; + +const mountApp = (STUDIP, createApp, element) => { + const getHttpClient = () => + axios.create({ + baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true), + headers: { + 'Content-Type': 'application/vnd.api+json', + }, + }); + + const httpClient = getHttpClient(); + + const store = new Vuex.Store({ + modules: { + courseware: CoursewareAdminModule, + ...mapResourceModules({ + names: [ + 'courseware-templates', + ], + httpClient, + }), + }, + }); + + store.dispatch('courseware-templates/loadAll'); + + const app = createApp({ + render: (h) => h(AdminApp), + store + }); + + app.$mount(element); + + return app; +} + +export default mountApp; \ No newline at end of file diff --git a/resources/vue/courseware-content-bookmark-app.js b/resources/vue/courseware-content-bookmark-app.js new file mode 100755 index 00000000000..8e9a43c7bfe --- /dev/null +++ b/resources/vue/courseware-content-bookmark-app.js @@ -0,0 +1,81 @@ +import ContentBookmarkApp from './components/courseware/ContentBookmarkApp.vue'; +import { mapResourceModules } from '@elan-ev/reststate-vuex'; +import Vuex from 'vuex'; +import CoursewareModule from './store/courseware/courseware.module'; +import axios from 'axios'; + +const mountApp = (STUDIP, createApp, element) => { + const getHttpClient = () => + axios.create({ + baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true), + headers: { + 'Content-Type': 'application/vnd.api+json', + }, + }); + + const httpClient = getHttpClient(); + + const store = new Vuex.Store({ + modules: { + courseware: CoursewareModule, + ...mapResourceModules({ + names: [ + 'activities', + 'users', + 'courses', + 'course-memberships', + 'courseware-blocks', + 'courseware-block-comments', + 'courseware-block-feedback', + 'courseware-containers', + 'courseware-instances', + 'courseware-structural-elements', + 'courseware-user-data-fields', + 'courseware-user-progresses', + 'users', + 'institutes', + 'semesters', + 'sem-classes', + 'sem-types', + 'status-groups', + ], + httpClient, + }), + }, + }); + let entry_id = null; + let entry_type = null; + let elem; + + 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; + } + } + } + + store.dispatch('setUserId', STUDIP.USER_ID); + store.dispatch('users/loadById', {id: STUDIP.USER_ID}); + store.dispatch('loadUsersBookmarks', STUDIP.USER_ID); + store.dispatch('setHttpClient', httpClient); + store.dispatch('coursewareContext', { + id: entry_id, + type: entry_type, + }); + + const app = createApp({ + render: (h) => h(ContentBookmarkApp), + store + }); + + app.$mount(element); + + return app; +} + +export default mountApp; \ No newline at end of file diff --git a/resources/vue/courseware-content-overview-app.js b/resources/vue/courseware-content-overview-app.js new file mode 100755 index 00000000000..e1f15ef2450 --- /dev/null +++ b/resources/vue/courseware-content-overview-app.js @@ -0,0 +1,97 @@ +import ContentOverviewApp from './components/courseware/ContentOverviewApp.vue'; +import CoursewareStructureModule from './store/courseware/structure.module'; +import { mapResourceModules } from '@elan-ev/reststate-vuex'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import CoursewareModule from './store/courseware/courseware.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 getHttpClient = () => + axios.create({ + baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true), + headers: { + 'Content-Type': 'application/vnd.api+json', + }, + }); + + const httpClient = getHttpClient(); + + const store = new Vuex.Store({ + modules: { + courseware: CoursewareModule, + 'courseware-structure': CoursewareStructureModule, + ...mapResourceModules({ + names: [ + 'activities', + 'users', + 'courses', + 'course-memberships', + 'courseware-blocks', + 'courseware-block-comments', + 'courseware-block-feedback', + 'courseware-containers', + 'courseware-instances', + 'courseware-structural-elements', + 'courseware-templates', + 'courseware-user-data-fields', + 'courseware-user-progresses', + 'file-refs', + 'users', + 'institutes', + 'semesters', + 'sem-classes', + 'sem-types', + 'status-groups', + ], + httpClient, + }), + }, + }); + let entry_id = null; + let entry_type = null; + let licenses = null; + let elem; + + 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); + } + } + } + + 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'); + store.dispatch('setHttpClient', httpClient); + store.dispatch('licenses', licenses); + store.dispatch('coursewareContext', { + id: entry_id, + type: entry_type, + }); + + const app = createApp({ + render: (h) => h(ContentOverviewApp), + store + }); + + app.$mount(element); + + return app; +} + +export default mountApp; diff --git a/resources/vue/courseware-dashboard-app.js b/resources/vue/courseware-dashboard-app.js index 3a67756667d..608341b8897 100755 --- a/resources/vue/courseware-dashboard-app.js +++ b/resources/vue/courseware-dashboard-app.js @@ -1,8 +1,89 @@ import DashboardApp from './components/courseware/DashboardApp.vue'; +import { mapResourceModules } from '@elan-ev/reststate-vuex'; +import Vuex from 'vuex'; +import CoursewareModule from './store/courseware/courseware.module'; +import CoursewareStructureModule from './store/courseware/structure.module'; +import axios from 'axios'; const mountApp = (STUDIP, createApp, element) => { + const getHttpClient = () => + axios.create({ + baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true), + headers: { + 'Content-Type': 'application/vnd.api+json', + }, + }); + + const httpClient = getHttpClient(); + + const store = new Vuex.Store({ + modules: { + courseware: CoursewareModule, + 'courseware-structure': CoursewareStructureModule, + ...mapResourceModules({ + names: [ + 'activities', + 'users', + 'courses', + 'course-memberships', + 'courseware-blocks', + 'courseware-block-comments', + 'courseware-block-feedback', + 'courseware-containers', + 'courseware-instances', + 'courseware-structural-elements', + 'courseware-task-feedback', + 'courseware-task-groups', + 'courseware-tasks', + 'courseware-user-data-fields', + 'courseware-user-progresses', + 'files', + 'file-refs', + 'folders', + 'users', + 'institutes', + 'semesters', + 'sem-classes', + 'sem-types', + 'status-groups', + ], + httpClient, + }), + }, + }); + let entry_id = null; + let entry_type = null; + let elem; + + 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; + } + } + } + + store.dispatch('setUserId', STUDIP.USER_ID); + store.dispatch('users/loadById', { id: STUDIP.USER_ID }); + store.dispatch('setHttpClient', httpClient); + store.dispatch('coursewareContext', { + id: entry_id, + type: entry_type, + }); + store.dispatch('courseware-tasks/loadAll', { + options: { + 'filter[cid]': entry_id, + include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + }, + }); + const app = createApp({ render: (h) => h(DashboardApp), + store, }); app.$mount(element); diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index ffdae6a70c6..4135e2c08ec 100755 --- a/resources/vue/courseware-index-app.js +++ b/resources/vue/courseware-index-app.js @@ -97,6 +97,11 @@ const mountApp = (STUDIP, createApp, element) => { 'courseware-containers', 'courseware-instances', 'courseware-structural-elements', + 'courseware-structural-element-comments', + 'courseware-structural-element-feedback', + 'courseware-task-feedback', + 'courseware-task-groups', + 'courseware-tasks', 'courseware-user-data-fields', 'courseware-user-progresses', 'files', diff --git a/resources/vue/courseware-manager-app.js b/resources/vue/courseware-manager-app.js index fc700a22644..fccea2beb5e 100755 --- a/resources/vue/courseware-manager-app.js +++ b/resources/vue/courseware-manager-app.js @@ -31,6 +31,8 @@ const mountApp = (STUDIP, createApp, element) => { 'courseware-containers', 'courseware-instances', 'courseware-structural-elements', + 'courseware-task-groups', + 'courseware-tasks', 'courseware-user-data-fields', 'courseware-user-progresses', 'files', diff --git a/resources/vue/mixins/courseware/task-helper.js b/resources/vue/mixins/courseware/task-helper.js new file mode 100755 index 00000000000..ecf4ff30492 --- /dev/null +++ b/resources/vue/mixins/courseware/task-helper.js @@ -0,0 +1,66 @@ +export default { + methods: { + getStatus(task) { + let status = {}; + const now = new Date(Date.now()); + const submissionDate = new Date(task.attributes['submission-date']); + let limit = new Date(); + limit.setDate(now.getDate() + 3); + status.canSubmit = true; + + if (now < submissionDate) { + status.shape = 'span-empty'; + status.role = 'status-green'; + status.description = this.$gettext('Aufgabe bereit'); + } + if (task.attributes.renewal !== 'granted') { + if (limit > submissionDate) { + status.shape = 'span-3quarter'; + status.role = 'status-yellow'; + status.description = this.$gettext('Aufgabe muss bald abgegeben werden'); + } + + if (now >= submissionDate) { + status.canSubmit = false; + status.shape = 'span-full'; + status.role = 'status-red'; + status.description = this.$gettext('Abgabe ist nicht bis zur Abgabefrist erfolgt'); + } + } else { + const renewalDate = new Date(task.attributes['renewal-date']); + if (limit > renewalDate) { + status.shape = 'span-3quarter'; + status.role = 'status-yellow'; + status.description = this.$gettext('Aufgabe muss bald abgegeben werden'); + } + + if (now >= renewalDate) { + status.canSubmit = false; + status.shape = 'span-full'; + status.role = 'status-red'; + status.description = this.$gettext('Abgabe ist nicht bis zur verlängerten Abgabefrist erfolgt'); + } + } + + if (task.attributes.submitted) { + status.shape = 'span-full'; + status.role = 'status-green'; + status.description = this.$gettext('Aufgabe abgegeben'); + } + + return status; + }, + getLinkToElement(elementId) { + return ( + STUDIP.URLHelper.base_url + + 'dispatch.php/course/courseware/?cid=' + + STUDIP.URLHelper.parameters.cid + + '#/structural_element/' + + elementId + ); + }, + getReadableDate(date) { + return new Date(date).toLocaleDateString(); + }, + }, +}; diff --git a/resources/vue/store/courseware/courseware-admin.module.js b/resources/vue/store/courseware/courseware-admin.module.js new file mode 100755 index 00000000000..ea287468c43 --- /dev/null +++ b/resources/vue/store/courseware/courseware-admin.module.js @@ -0,0 +1,47 @@ +const getDefaultState = () => { + return { + adminViewMode: 'templates', + showAddTemplateDialog: false, + }; +}; + +const initialState = getDefaultState(); + +const getters = { + adminViewMode(state) { + return state.adminViewMode; + }, + showAddTemplateDialog(state) { + return state.showAddTemplateDialog; + }, +}; + +export const state = { ...initialState }; + +export const actions = { + // setters + adminViewMode(context, view) { + context.commit('setAdminViewMode', view); + }, + showAddTemplateDialog(context, showDialog) { + context.commit('setShowAddTemplateDialog', showDialog); + }, + + // other actions +}; + +export const mutations = { + setAdminViewMode(state, mode) { + state.adminViewMode = mode; + }, + setShowAddTemplateDialog(state, showDialog) { + state.showAddTemplateDialog = showDialog; + }, +}; + +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 8a4326f0fa6..dd3c61bbbb1 100755 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -23,8 +23,10 @@ const getDefaultState = () => { urlHelper: null, userId: null, viewMode: 'read', + dashboardViewMode: 'default', filingData: {}, userIsTeacher: false, + teacherStatusLoaded: false, showStructuralElementEditDialog: false, showStructuralElementAddDialog: false, @@ -33,6 +35,8 @@ const getDefaultState = () => { showStructuralElementDeleteDialog: false, showStructuralElementOerDialog: false, + structuralElementSortMode: false, + importFilesState: '', importFilesProgress: 0, importStructuresState: '', @@ -41,6 +45,11 @@ const getDefaultState = () => { exportState: '', exportProgress: 0, + + purposeFilter: 'all', + showOverviewElementAddDialog: false, + + bookmarkFilter: 'all', }; }; @@ -86,6 +95,9 @@ const getters = { viewMode(state) { return state.viewMode; }, + dashboardViewMode(state) { + return state.dashboardViewMode; + }, showToolbar(state) { return state.showToolbar; }, @@ -119,6 +131,9 @@ const getters = { userIsTeacher(state) { return state.userIsTeacher; }, + teacherStatusLoaded(state) { + return state.teacherStatusLoaded; + }, pluginManager(state) { return state.pluginManager; }, @@ -143,6 +158,12 @@ const getters = { showStructuralElementDeleteDialog(state) { return state.showStructuralElementDeleteDialog; }, + showOverviewElementAddDialog(state) { + return state.showOverviewElementAddDialog; + }, + structuralElementSortMode(state) { + return state.structuralElementSortMode; + }, importFilesState(state) { return state.importFilesState; }, @@ -164,6 +185,12 @@ const getters = { exportProgress(state) { return state.exportProgress; }, + purposeFilter(state) { + return state.purposeFilter; + }, + bookmarkFilter(state) { + return state.bookmarkFilter; + }, }; export const state = { ...initialState }; @@ -198,11 +225,42 @@ export const actions = { }; const relationship = 'file-refs'; - return dispatch('file-refs/loadRelated', { parent, relationship }, { root: true }) - .then(() => rootGetters['file-refs/related']({ + return dispatch('file-refs/loadRelated', { parent, relationship }, { root: true }).then(() => + rootGetters['file-refs/related']({ parent, relationship, - })); + }) + ); + }, + + async loadCoursewareActivities({ dispatch, rootGetters }, { userId, courseId }) { + const parent = { + type: 'users', + id: userId, + }; + const relationship = 'activitystream'; + + const options = { + 'filter[context_type]': 'course', + 'filter[context_id]': courseId, + 'filter[object_type]': 'courseware', + include: 'actor, context, object', + }; + + await dispatch('users/loadRelated', { parent, relationship, options }, { root: true }); + + const activities = rootGetters['users/all']; + + for (const activity of activities) { + //load parents for breadcrumb + if (activity.type == 'activities') { + await this.dispatch('courseware-structural-elements/loadById', { + id: activity.relationships.object.meta['object-id'], + }); + } + } + + return activities; }, async createFile(context, { file, filedata, folder }) { @@ -317,8 +375,8 @@ export const actions = { // console.log(resp); }); }, - async copyStructuralElement({ dispatch, getters, rootGetters }, { parentId, element }) { - const copy = { data: { parent_id: parentId, }, }; + async copyStructuralElement({ dispatch, getters, rootGetters }, { parentId, element, removePurpose }) { + const copy = { data: { parent_id: parentId, remove_purpose: removePurpose } }; const result = await state.httpClient.post(`courseware-structural-elements/${element.id}/copy`, copy); const id = result.data.data.id; @@ -456,6 +514,28 @@ export const actions = { return dispatch('loadStructuralElement', currentId); }, + async createStructuralElementWithTemplate({ dispatch }, { attributes, parentId, currentId, templateId }) { + const data = { + attributes, + relationships: { + parent: { + data: { + type: 'courseware-structural-elements', + id: parentId, + }, + }, + }, + templateId: templateId, + }; + await dispatch('courseware-structural-elements/create', data, { root: true }); + + const options = { + include: 'children', + }; + + return dispatch('courseware-structural-elements/loadById', { id: currentId, options }, { root: true }); + }, + async deleteStructuralElement({ dispatch }, { id, parentId }) { const data = { id: id, @@ -631,6 +711,10 @@ export const actions = { context.commit('coursewareViewModeSet', view); }, + setDashboardViewMode(context, view) { + context.commit('setDashboardViewMode', view); + }, + coursewareShowToolbar(context, toolbar) { context.commit('coursewareShowToolbarSet', toolbar); }, @@ -672,50 +756,58 @@ export const actions = { }, showElementEditDialog(context, bool) { - context.commit('setShowStructuralElementEditDialog', bool) + context.commit('setShowStructuralElementEditDialog', bool); }, showElementAddDialog(context, bool) { - context.commit('setShowStructuralElementAddDialog', bool) + context.commit('setShowStructuralElementAddDialog', bool); }, showElementExportDialog(context, bool) { - context.commit('setShowStructuralElementExportDialog', bool) + context.commit('setShowStructuralElementExportDialog', bool); }, showElementInfoDialog(context, bool) { - context.commit('setShowStructuralElementInfoDialog', bool) + context.commit('setShowStructuralElementInfoDialog', bool); }, showElementOerDialog(context, bool) { - context.commit('setShowStructuralElementOerDialog', bool) + context.commit('setShowStructuralElementOerDialog', bool); }, showElementDeleteDialog(context, bool) { - context.commit('setShowStructuralElementDeleteDialog', bool) + context.commit('setShowStructuralElementDeleteDialog', bool); }, - setImportFilesState({commit}, state ) { - commit('setImportFilesState', state) + setShowOverviewElementAddDialog(context, bool) { + context.commit('setShowOverviewElementAddDialog', bool); }, - setImportFilesProgress({commit}, percent ) { - commit('setImportFilesProgress', percent) + + setStructuralElementSortMode({ commit }, bool) { + commit('setStructuralElementSortMode', bool); }, - setImportStructuresState({commit}, state ) { - commit('setImportStructuresState', state) + + setImportFilesState({ commit }, state) { + commit('setImportFilesState', state); }, - setImportStructuresProgress({commit}, percent ) { - commit('setImportStructuresProgress', percent) + setImportFilesProgress({ commit }, percent) { + commit('setImportFilesProgress', percent); }, - setImportErrors({commit}, errors) { + setImportStructuresState({ commit }, state) { + commit('setImportStructuresState', state); + }, + setImportStructuresProgress({ commit }, percent) { + commit('setImportStructuresProgress', percent); + }, + setImportErrors({ commit }, errors) { commit('setImportErrors', errors); }, - setExportState({commit}, state) { - commit('setExportState', state) + setExportState({ commit }, state) { + commit('setExportState', state); }, - setExportProgress({commit}, percent) { - commit('setExportProgress', percent) + setExportProgress({ commit }, percent) { + commit('setExportProgress', percent); }, addBookmark({ dispatch, rootGetters }, structuralElement) { @@ -811,7 +903,7 @@ export const actions = { parent, relationship, options: optionsWithPages, - resetRelated: false + resetRelated: false, }, { root: true } ); @@ -819,6 +911,24 @@ export const actions = { } while (rootGetters[`${type}/all`].length < rootGetters[`${type}/lastMeta`].page.total); }, + loadUsersBookmarks({ dispatch, rootGetters, state }, userId) { + const parent = { + type: 'users', + id: userId, + }; + const relationship = 'courseware-bookmarks'; + const options = { + include: 'course', + }; + + return dispatch('loadRelatedPaginated', { + type: 'courseware-structural-elements', + parent, + relationship, + options, + }); + }, + async loadUsersCourses({ dispatch, rootGetters, state }, { userId, withCourseware }) { const parent = { type: 'users', @@ -826,13 +936,13 @@ export const actions = { }; const relationship = 'course-memberships'; const options = { - include: 'course' + include: 'course', }; await dispatch('loadRelatedPaginated', { type: 'course-memberships', parent, relationship, - options + options, }); const memberships = rootGetters['course-memberships/related']({ @@ -844,7 +954,7 @@ export const actions = { for (let membership of memberships) { if ( membership.attributes.permission === 'dozent' && - state.context.id !== membership.relationships.course.data.id + state.context.id !== membership.relationships.course.data.id ) { const course = rootGetters['courses/related']({ parent: membership, relationship: 'course' }); if (!withCourseware) { @@ -938,6 +1048,77 @@ export const actions = { return dispatch('loadFeedback', blockId); }, + + async createTaskGroup({ dispatch, rootGetters }, { taskGroup }) { + await dispatch('courseware-task-groups/create', taskGroup, { root: true }); + + const id = taskGroup.relationships.target.data.id; + const target = rootGetters['courseware-structural-elements/byId']({ id }); + + return dispatch('courseware-structure/loadDescendants', { root: target }); + }, + + async loadTask({ dispatch }, { taskId }) { + return dispatch( + 'courseware-tasks/loadById', + { + id: taskId, + options: { + include: 'solver,task-group,task-group.lecturer', + }, + }, + { root: true } + ); + }, + + async updateTask({ dispatch }, { attributes, taskId }) { + const task = { + type: 'courseware-tasks', + attributes: attributes, + id: taskId, + }; + await dispatch('courseware-tasks/update', task, { root: true }); + + return dispatch('loadTask', { taskId: task.id }); + }, + + async deleteTask({ dispatch }, { task }) { + const data = { + id: task.id, + }; + await dispatch('courseware-tasks/delete', data, { root: true }); + }, + + async createTaskFeedback({ dispatch }, { taskFeedback }) { + await dispatch('courseware-task-feedback/create', taskFeedback, { root: true }); + + return dispatch('loadTask', { taskId: taskFeedback.relationships.task.data.id }); + }, + + async updateTaskFeedback({ dispatch }, { attributes, taskFeedbackId }) { + const taskFeedback = { + type: 'courseware-task-feedback', + attributes: attributes, + id: taskFeedbackId, + }; + await dispatch('courseware-task-feedback/update', taskFeedback, { root: true }); + + return dispatch('courseware-task-feedback/loadById', { id: taskFeedback.id }, { root: true }); + }, + + async deleteTaskFeedback({ dispatch }, { taskFeedbackId }) { + const data = { + id: taskFeedbackId, + }; + await dispatch('courseware-task-feedback/delete', data, { root: true }); + }, + + setPurposeFilter({ commit }, purpose) { + commit('setPurposeFilter', purpose); + }, + setBookmarkFilter({ commit }, course) { + commit('setBookmarkFilter', course); + }, }; /* eslint no-param-reassign: ["error", { "props": false }] */ @@ -971,6 +1152,10 @@ export const mutations = { state.viewMode = data; }, + setDashboardViewMode(state, data) { + state.dashboardViewMode = data; + }, + coursewareShowToolbarSet(state, data) { state.showToolbar = data; }, @@ -1012,6 +1197,7 @@ export const mutations = { }, setUserIsTeacher(state, isTeacher) { + state.teacherStatusLoaded = true; state.userIsTeacher = isTeacher; }, @@ -1047,6 +1233,14 @@ export const mutations = { state.showStructuralElementDeleteDialog = showDelete; }, + setShowOverviewElementAddDialog(state, showAdd) { + state.showOverviewElementAddDialog = showAdd; + }, + + setStructuralElementSortMode(state, mode) { + state.structuralElementSortMode = mode; + }, + setImportFilesState(state, importFilesState) { state.importFilesState = importFilesState; }, @@ -1071,8 +1265,13 @@ export const mutations = { }, setExportProgress(state, exportProgress) { state.exportProgress = exportProgress; - } - + }, + setPurposeFilter(state, purpose) { + state.purposeFilter = purpose; + }, + setBookmarkFilter(state, course) { + state.bookmarkFilter = course; + }, }; export default { -- GitLab