From b11152c9e53633e708cb71d76699c47faba63518 Mon Sep 17 00:00:00 2001 From: Ron Lucke <lucke@elan-ev.de> Date: Fri, 24 Jun 2022 08:34:28 +0000 Subject: [PATCH] =?UTF-8?q?StEP00363:=20Externer=20Ansicht=20als=20Link=20?= =?UTF-8?q?f=C3=BCr=20Courseware-Seiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #918 Merge request studip/studip!638 --- app/controllers/contents/courseware.php | 14 + app/controllers/courseware/public.php | 28 ++ app/views/contents/courseware/releases.php | 6 + app/views/courseware/public/index.php | 16 ++ .../5.2.13_add_courseware_public_links.php | 31 +++ lib/classes/JsonApi/RouteMap.php | 9 + .../JsonApi/Routes/Courseware/Authority.php | 33 +++ .../Routes/Courseware/PublicLinksCreate.php | 72 +++++ .../Routes/Courseware/PublicLinksDelete.php | 34 +++ .../Routes/Courseware/PublicLinksIndex.php | 33 +++ .../Routes/Courseware/PublicLinksShow.php | 35 +++ .../Routes/Courseware/PublicLinksUpdate.php | 78 ++++++ .../PublicStructuralElementsIndex.php | 47 ++++ .../PublicStructuralElementsShow.php | 52 ++++ lib/classes/JsonApi/SchemaMap.php | 1 + .../JsonApi/Schemas/Courseware/Block.php | 32 ++- .../JsonApi/Schemas/Courseware/PublicLink.php | 54 ++++ lib/models/Courseware/PublicLink.php | 59 ++++ lib/navigation/ContentsNavigation.php | 4 + .../javascripts/bootstrap/courseware.js | 22 ++ .../assets/stylesheets/scss/courseware.scss | 35 +++ .../courseware/ContentReleasesApp.vue | 18 ++ .../courseware/CoursewareActionWidget.vue | 10 + .../courseware/CoursewareContentLinks.vue | 173 ++++++++++++ .../courseware/CoursewareDefaultBlock.vue | 10 +- .../courseware/CoursewareRibbonToolbar.vue | 32 ++- .../CoursewareStructuralElement.vue | 76 ++++++ .../components/courseware/CoursewareTree.vue | 16 +- .../vue/components/courseware/PublicApp.vue | 141 ++++++++++ .../PublicCoursewareStructuralElement.vue | 256 ++++++++++++++++++ .../vue/courseware-content-releases-app.js | 70 +++++ resources/vue/courseware-index-app.js | 1 + resources/vue/courseware-public-app.js | 127 +++++++++ .../courseware/courseware-links.module.js | 29 ++ .../courseware/courseware-public.module.js | 175 ++++++++++++ .../vue/store/courseware/courseware.module.js | 34 +++ .../courseware/public-structure.module.js | 78 ++++++ 37 files changed, 1913 insertions(+), 28 deletions(-) create mode 100644 app/controllers/courseware/public.php create mode 100644 app/views/contents/courseware/releases.php create mode 100644 app/views/courseware/public/index.php create mode 100644 db/migrations/5.2.13_add_courseware_public_links.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PublicLinksCreate.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PublicLinksDelete.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PublicLinksIndex.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PublicLinksShow.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PublicLinksUpdate.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsIndex.php create mode 100644 lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsShow.php create mode 100644 lib/classes/JsonApi/Schemas/Courseware/PublicLink.php create mode 100644 lib/models/Courseware/PublicLink.php create mode 100644 resources/vue/components/courseware/ContentReleasesApp.vue create mode 100644 resources/vue/components/courseware/CoursewareContentLinks.vue create mode 100644 resources/vue/components/courseware/PublicApp.vue create mode 100644 resources/vue/components/courseware/PublicCoursewareStructuralElement.vue create mode 100644 resources/vue/courseware-content-releases-app.js create mode 100644 resources/vue/courseware-public-app.js create mode 100644 resources/vue/store/courseware/courseware-links.module.js create mode 100644 resources/vue/store/courseware/courseware-public.module.js create mode 100644 resources/vue/store/courseware/public-structure.module.js diff --git a/app/controllers/contents/courseware.php b/app/controllers/contents/courseware.php index f618d796598..c1ca32137bd 100755 --- a/app/controllers/contents/courseware.php +++ b/app/controllers/contents/courseware.php @@ -185,6 +185,20 @@ class Contents_CoursewareController extends AuthenticatedController $this->setBookmarkSidebar(); } + /** + * Show users releases + * + * @SuppressWarnings(PHPMD.CamelCaseMethodName) + * @SuppressWarnings(PHPMD.Superglobals) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + + public function releases_action() + { + Navigation::activateItem('/contents/courseware/releases'); + $this->user_id = $GLOBALS['user']->id; + } + private function setBookmarkSidebar() { $sidebar = Sidebar::Get(); diff --git a/app/controllers/courseware/public.php b/app/controllers/courseware/public.php new file mode 100644 index 00000000000..aa5a3099b32 --- /dev/null +++ b/app/controllers/courseware/public.php @@ -0,0 +1,28 @@ +<?php + +use Courseware\PublicLink; + +class Courseware_PublicController extends StudipController +{ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + PageLayout::setTitle(_('Courseware')); + PageLayout::setHelpKeyword('Basis.Courseware'); + } + + public function index_action() + { + $this->invalid = true; + $this->link_id = Request::option('link'); + if ($this->link_id) { + $publicLink = PublicLink::find($this->link_id); + $this->invalid = $publicLink === null; + if (!$this->invalid) { + $this->expired = $publicLink->isExpired(); + $this->link_pass = $publicLink->password; + $this->entry_element_id = $publicLink->structural_element_id; + } + } + } +} diff --git a/app/views/contents/courseware/releases.php b/app/views/contents/courseware/releases.php new file mode 100644 index 00000000000..972526f5a09 --- /dev/null +++ b/app/views/contents/courseware/releases.php @@ -0,0 +1,6 @@ +<div + id="courseware-content-releases-app" + entry-type="users" + entry-id="<?= htmlReady($user_id) ?>" +> +</div> diff --git a/app/views/courseware/public/index.php b/app/views/courseware/public/index.php new file mode 100644 index 00000000000..3696d1d1047 --- /dev/null +++ b/app/views/courseware/public/index.php @@ -0,0 +1,16 @@ +<? if (!$expired && !$invalid): ?> +<div + id="courseware-public-app" + link-id="<?= htmlReady($link_id) ?>" + link-pass="<?= htmlReady($link_pass) ?>" + entry-type="public" + entry-element-id="<?= htmlReady($entry_element_id) ?>" +> +</div> +<? endif; ?> +<? if ($expired): ?> + <?= MessageBox::warning(_('Der Link zu dieser Seite ist abgelaufen.'))->hideClose() ?> +<? endif; ?> +<? if ($invalid): ?> + <?= MessageBox::error(_('Es wurde kein gültiger Link aufgerufen.'))->hideClose() ?> +<? endif; ?> diff --git a/db/migrations/5.2.13_add_courseware_public_links.php b/db/migrations/5.2.13_add_courseware_public_links.php new file mode 100644 index 00000000000..1a9275cd692 --- /dev/null +++ b/db/migrations/5.2.13_add_courseware_public_links.php @@ -0,0 +1,31 @@ +<?php +final class AddCoursewarePublicLinks extends Migration +{ + public function description() + { + return 'Create Courseware public links database table'; + } + + public function up() + { + \DBManager::get()->exec("CREATE TABLE `cw_public_links` ( + `id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `structural_element_id` int(11) NOT NULL, + `password` varbinary(64) NOT NULL, + `expire_date` int(11) NOT NULL, + `mkdate` int(11) NOT NULL, + `chdate` int(11) NOT NULL, + + PRIMARY KEY (`id`), + INDEX index_user_id (`user_id`), + INDEX index_structural_element_id (`structural_element_id`) + ) + "); + } + + public function down() + { + \DBManager::get()->exec("DROP TABLE IF EXISTS `cw_public_links`"); + } +} diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 0ebea653379..baccc262569 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -143,6 +143,9 @@ class RouteMap $group->get('/semesters/{id}', Routes\SemestersShow::class)->setName('get-semester'); $group->get('/studip/properties', Routes\Studip\PropertiesIndex::class); + + $group->get('/public/courseware/{link_id}/courseware-structural-elements/{id}', Routes\Courseware\PublicStructuralElementsShow::class); + $group->get('/public/courseware/{link_id}/courseware-structural-elements', Routes\Courseware\PublicStructuralElementsIndex::class); } private function getAuthenticator(): callable @@ -449,6 +452,12 @@ class RouteMap $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); + + $group->get('/courseware-public-links/{id}', Routes\Courseware\PublicLinksShow::class); + $group->get('/courseware-public-links', Routes\Courseware\PublicLinksIndex::class); + $group->post('/courseware-public-links', Routes\Courseware\PublicLinksCreate::class); + $group->patch('/courseware-public-links/{id}', Routes\Courseware\PublicLinksUpdate::class); + $group->delete('/courseware-public-links/{id}', Routes\Courseware\PublicLinksDelete::class); } 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 fa84c0a0cd6..9c5a37b4fbc 100755 --- a/lib/classes/JsonApi/Routes/Courseware/Authority.php +++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php @@ -14,6 +14,7 @@ use Courseware\TaskGroup; use Courseware\Template; use Courseware\UserDataField; use Courseware\UserProgress; +use Courseware\PublicLink; use User; /** @@ -427,4 +428,36 @@ class Authority return self::canCreateTemplate($user); } + public static function canIndexPublicLinks(User $user): bool + { + return self::canCreatePublicLink($user); + } + + public static function canShowPublicLink(User $user, PublicLink $resource): bool + { + return self::canUpdatePublicLink($user, $resource); + } + + public static function canCreatePublicLink(User $user): bool + { + return true; + } + + public static function canUpdatePublicLink(User $user, PublicLink $resource): bool + { + return $resource->user_id === $user->id; + } + + public static function canDeletePublicLink(User $user, PublicLink $resource): bool + { + return self::canUpdatePublicLink($user, $resource); + } + + public static function canShowPublicStructuralElement(StructuralElement $resource): bool + { + $publicLink = PublicLink::findOneBySQL('structural_element_id = ?', [$resource->id]); + + return (bool) $publicLink; + } + } diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksCreate.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksCreate.php new file mode 100644 index 00000000000..8d5a6e6b692 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksCreate.php @@ -0,0 +1,72 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\PublicLink; +use JsonApi\Errors\AuthorizationFailedException; +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; + +/** + * Create a Template. + */ +class PublicLinksCreate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + if (!Authority::canCreatePublicLink($user = $this->getUser($request))) { + throw new AuthorizationFailedException(); + } + + $publicLink = $this->createPublicLink($json, $user); + + return $this->getCreatedResponse($publicLink); + } + + /** + * @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.relationships.structural-element.data.id')) { + return 'Missing `structural-element-id` value.'; + } + + } + + private function createPublicLink(array $json, $user): PublicLink + { + $get = function ($key, $default = '') use ($json) { + return self::arrayGet($json, $key, $default); + }; + + $publicLink = new PublicLink(); + + $publicLink->setId($publicLink->getNewId()); + $publicLink->user_id = $user->id; + + $publicLink->structural_element_id = $get('data.relationships.structural-element.data.id'); + $publicLink->password = str_replace(' ', '', $get('data.attributes.password')); + $expire_date = $get('data.attributes.expire-date'); + $expireDate = self::fromISO8601($expire_date); + $publicLink->expire_date = $expireDate->getTimestamp(); + + $publicLink->store(); + + return $publicLink; + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksDelete.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksDelete.php new file mode 100644 index 00000000000..4e06b0ea7c8 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksDelete.php @@ -0,0 +1,34 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\PublicLink; +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 PublicLink. + */ +class PublicLinksDelete extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = PublicLink::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + if (!Authority::canDeletePublicLink($user = $this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + $resource->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksIndex.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksIndex.php new file mode 100644 index 00000000000..f76361086fb --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksIndex.php @@ -0,0 +1,33 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\PublicLink; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays all PublicLinks + */ +class PublicLinksIndex extends JsonApiController +{ + + protected $allowedIncludePaths = ['structural-element']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $user = $this->getUser($request); + if (!Authority::canIndexPublicLinks($user)) { + throw new AuthorizationFailedException(); + } + + $resources = PublicLink::findBySQL('user_id = ? ORDER BY mkdate', [$user->id]); + + return $this->getContentResponse($resources); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksShow.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksShow.php new file mode 100644 index 00000000000..46495125918 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksShow.php @@ -0,0 +1,35 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\PublicLink; +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 PublicLink. + */ +class PublicLinksShow extends JsonApiController +{ + protected $allowedIncludePaths = ['structural-element']; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = PublicLink::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowPublicLink($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicLinksUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PublicLinksUpdate.php new file mode 100644 index 00000000000..9c43f84cfe7 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PublicLinksUpdate.php @@ -0,0 +1,78 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\PublicLink; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\Errors\UnprocessableEntityException; +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 PublicLink. + */ +class PublicLinksUpdate extends JsonApiController +{ + use TimestampTrait; + use ValidationTrait; + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = PublicLink::find($args['id']); + if (!$resource) { + throw new RecordNotFoundException(); + } + $json = $this->validate($request, $resource); + if (!Authority::canUpdatePublicLink($user = $this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + $resource = $this->updatePublicLink($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.attributes.expire-date')) { + $expire_date = self::arrayGet($json, 'data.attributes.expire-date'); + if (!self::isValidTimestamp($expire_date)) { + return '`expire-date` is not an ISO 8601 timestamp.'; + } + } + } + + private function updatePublicLink(PublicLink $resource, array $json): PublicLink + { + $get = function ($key, $default = '') use ($json) { + return self::arrayGet($json, $key, $default); + }; + + $resource->password = $get('data.attributes.password'); + + $expire_date = $get('data.attributes.expire-date'); + $expireDate = self::fromISO8601($expire_date); + $resource->expire_date = $expireDate->getTimestamp(); + + $resource->store(); + + return $resource; + } + +} diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsIndex.php b/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsIndex.php new file mode 100644 index 00000000000..75211ee327f --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsIndex.php @@ -0,0 +1,47 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElement; +use Courseware\PublicLink; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Neomerx\JsonApi\Contracts\Http\ResponsesInterface; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays one StructuralElement. + */ +class PublicStructuralElementsIndex extends JsonApiController +{ + + protected $allowedIncludePaths = []; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + + $publicLink = PublicLink::find($args['link_id']); + + if (!$publicLink) { + throw new AuthorizationFailedException(); + } + + $root = StructuralElement::find($publicLink->structural_element_id); + if (!$root) { + throw new RecordNotFoundException(); + } + + if (!$publicLink->canVisitElement($root)) { + throw new AuthorizationFailedException(); + } + + $resources = array_merge([$root], $root->findDescendants()); + + return $this->getContentResponse($resources); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsShow.php b/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsShow.php new file mode 100644 index 00000000000..eeb43fc31d1 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/PublicStructuralElementsShow.php @@ -0,0 +1,52 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use Courseware\StructuralElement; +use Courseware\PublicLink; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Neomerx\JsonApi\Contracts\Http\ResponsesInterface; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Displays one StructuralElement. + */ +class PublicStructuralElementsShow extends JsonApiController +{ + protected $allowedIncludePaths = [ + 'children', + 'containers', + 'containers.blocks', + 'course', + 'parent', + ]; + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $resource = StructuralElement::find($args['id']); + $publicLink = PublicLink::find($args['link_id']); + + if (!$publicLink) { + throw new AuthorizationFailedException(); + } + + /** @var ?StructuralElement $resource*/ + if (!$resource) { + throw new RecordNotFoundException(); + } + + if (!$publicLink->canVisitElement($resource)) { + throw new AuthorizationFailedException(); + } + + $meta = []; + + return $this->getContentResponse($resource, ResponsesInterface::HTTP_OK, [], $meta); + } +} diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index 1af7a90b380..e7168cd9989 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -65,6 +65,7 @@ class SchemaMap \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class, \Courseware\TaskFeedback::class => Schemas\Courseware\TaskFeedback::class, \Courseware\Template::class => Schemas\Courseware\Template::class, + \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class, ]; } } diff --git a/lib/classes/JsonApi/Schemas/Courseware/Block.php b/lib/classes/JsonApi/Schemas/Courseware/Block.php index 63fd96e47c6..0bbe578a2de 100755 --- a/lib/classes/JsonApi/Schemas/Courseware/Block.php +++ b/lib/classes/JsonApi/Schemas/Courseware/Block.php @@ -101,21 +101,23 @@ class Block extends SchemaProvider ]; $user = $this->currentUser; - $userDataField = UserDataField::getUserDataField($user, $resource); - $relationships[self::REL_USERDATAFIELD] = [ - self::RELATIONSHIP_LINKS => [ - Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_USERDATAFIELD), - ], - self::RELATIONSHIP_DATA => $userDataField, - ]; - - $userProgress = UserProgress::getUserProgress($user, $resource); - $relationships[self::REL_USERPROGRESS] = [ - self::RELATIONSHIP_LINKS => [ - Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_USERPROGRESS), - ], - self::RELATIONSHIP_DATA => $userProgress, - ]; + if ($user) { + $userDataField = UserDataField::getUserDataField($user, $resource); + $relationships[self::REL_USERDATAFIELD] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_USERDATAFIELD), + ], + self::RELATIONSHIP_DATA => $userDataField, + ]; + + $userProgress = UserProgress::getUserProgress($user, $resource); + $relationships[self::REL_USERPROGRESS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_USERPROGRESS), + ], + self::RELATIONSHIP_DATA => $userProgress, + ]; + } if ($resource->files) { $filesLink = $this->getRelationshipRelatedLink($resource, self::REL_FILES); diff --git a/lib/classes/JsonApi/Schemas/Courseware/PublicLink.php b/lib/classes/JsonApi/Schemas/Courseware/PublicLink.php new file mode 100644 index 00000000000..13d14e06999 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Courseware/PublicLink.php @@ -0,0 +1,54 @@ +<?php + +namespace JsonApi\Schemas\Courseware; + +use JsonApi\Schemas\SchemaProvider; +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class PublicLink extends SchemaProvider +{ + const TYPE = 'courseware-public-links'; + + const REL_STRUCTURAL_ELEMENT = 'structural-element'; + + /** + * {@inheritdoc} + */ + public function getId($resource): ?string + { + return $resource->id; + } + + /** + * {@inheritdoc} + */ + public function getAttributes($resource, ContextInterface $context): array + { + return [ + 'password' => $resource['password'], + 'expire-date' => $resource['expire_date'] ? date('Y-m-d', $resource['expire_date']) : null, + 'mkdate' => date('c', $resource['mkdate']), + 'chdate' => date('c', $resource['chdate']), + ]; + } + + /** + * {@inheritdoc} + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $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]; + + return $relationships; + } +} diff --git a/lib/models/Courseware/PublicLink.php b/lib/models/Courseware/PublicLink.php new file mode 100644 index 00000000000..d02e077b1a0 --- /dev/null +++ b/lib/models/Courseware/PublicLink.php @@ -0,0 +1,59 @@ +<?php + +namespace Courseware; + +/** +* Courseware's template. +* +* @author Ron Lucke <lucke@elan-ev.de> +* @license GPL2 or any later version +* +* @since Stud.IP 5.2 +* +* @property string $id database column +* @property int $structural_element_id database column +* @property string $password database column +* @property int $expire_date database column +* @property int $mkdate database column +* @property int $chdate database column +*/ +class PublicLink extends \SimpleORMap +{ + protected static function configure($config = []) + { + $config['db_table'] = 'cw_public_links'; + + $config['belongs_to']['structural_element'] = [ + 'class_name' => StructuralElement::class, + 'foreign_key' => 'structural_element_id', + ]; + + parent::configure($config); + } + + public function canVisitElement(StructuralElement $structuralElement): bool + { + if (!$structuralElement) { + return false; + } + + if ($structuralElement->isRootNode()) { + return $this->structural_element_id === $structuralElement->id; + } + + if ($this->structural_element_id === $structuralElement->id) { + return true; + } + + return $this->canVisitElement($structuralElement->parent); + } + + public function isExpired(): bool + { + if (!$this->expire_date) { + return false; + } + + return time() > $this->expire_date; + } +} diff --git a/lib/navigation/ContentsNavigation.php b/lib/navigation/ContentsNavigation.php index 735d1d0d04e..4e01acaadfd 100755 --- a/lib/navigation/ContentsNavigation.php +++ b/lib/navigation/ContentsNavigation.php @@ -59,6 +59,10 @@ class ContentsNavigation extends Navigation 'courseware_manager', new Navigation(_('Verwaltung persönlicher Lernmaterialien'), 'dispatch.php/contents/courseware/courseware_manager') ); + $courseware->addSubNavigation( + 'releases', + new Navigation(_('Freigaben'), 'dispatch.php/contents/courseware/releases') + ); $courseware->addSubNavigation( 'bookmarks', new Navigation(_('Lesezeichen'), 'dispatch.php/contents/courseware/bookmarks') diff --git a/resources/assets/javascripts/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js index 2c2938d17d6..87101506622 100755 --- a/resources/assets/javascripts/bootstrap/courseware.js +++ b/resources/assets/javascripts/bootstrap/courseware.js @@ -64,4 +64,26 @@ STUDIP.domReady(() => { }); }); } + + if (document.getElementById('courseware-public-app')) { + STUDIP.Vue.load().then(({ createApp }) => { + import( + /* webpackChunkName: "courseware-public-app" */ + '@/vue/courseware-public-app.js' + ).then(({ default: mountApp }) => { + return mountApp(STUDIP, createApp, '#courseware-public-app'); + }); + }); + } + + if (document.getElementById('courseware-content-releases-app')) { + STUDIP.Vue.load().then(({ createApp }) => { + import( + /* webpackChunkName: "courseware-content-links-app" */ + '@/vue/courseware-content-releases-app.js' + ).then(({ default: mountApp }) => { + return mountApp(STUDIP, createApp, '#courseware-content-releases-app'); + }); + }); + } }); diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index 175cbd70741..52431eb2a93 100755 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -510,6 +510,20 @@ $consum_ribbon_width: calc(100% - 58px); } } +#courseware-public-index { + .cw-ribbon-tools { + top: 127px; + + &.cw-ribbon-tools-consume { + top: 14px; + } + + &.cw-ribbon-tools-sticky { + top: 56px; + } + } +} + .cw-structural-element-consumemode { .cw-ribbon-tools { top: 14px; @@ -1933,6 +1947,9 @@ v i e w w i d g e t .cw-action-widget-oer{ @include background-icon(oer-campus, clickable); } + .cw-action-widget-link { + @include background-icon(group, clickable); + } } .cw-export-widget { .cw-export-widget-export{ @@ -4775,3 +4792,21 @@ cw tiles end /* * * * * * * * * * * * * i n p u t f i l e e n d * * * * * * * * * * * * * */ + +/* * * * * * * * * * * * +p u b l i c l i n k s +* * * * * * * * * * * */ +.cw-public-link-clipboard-button { + width: 16px; + height: 16px; + margin-left: 0.5em; + background-color: transparent; + border: none; + vertical-align: text-bottom; + @include background-icon(clipboard, clickable, 16); + cursor: pointer; + +} +/* * * * * * * * * * * * * * * +e n d p u b l i c l i n k s +* * * * * * * * * * * * * * */ \ No newline at end of file diff --git a/resources/vue/components/courseware/ContentReleasesApp.vue b/resources/vue/components/courseware/ContentReleasesApp.vue new file mode 100644 index 00000000000..7dd550079eb --- /dev/null +++ b/resources/vue/components/courseware/ContentReleasesApp.vue @@ -0,0 +1,18 @@ +<template> + <div class="cw-content-releases"> + <courseware-content-links /> + <courseware-companion-overlay /> + </div> +</template> + +<script> +import CoursewareContentLinks from './CoursewareContentLinks.vue'; +import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; + +export default { + components: { + CoursewareContentLinks, + CoursewareCompanionOverlay + } +} +</script> diff --git a/resources/vue/components/courseware/CoursewareActionWidget.vue b/resources/vue/components/courseware/CoursewareActionWidget.vue index c15174349e9..20df52240ec 100644 --- a/resources/vue/components/courseware/CoursewareActionWidget.vue +++ b/resources/vue/components/courseware/CoursewareActionWidget.vue @@ -35,6 +35,11 @@ <translate>Lesezeichen setzen</translate> </button> </li> + <li v-if="context.type === 'users'" class="cw-action-widget-link"> + <button @click="linkElement"> + <translate>Öffentlichen Link erzeugen</translate> + </button> + </li> <li v-if="!isOwner" class="cw-action-widget-oer"> <button @click="suggestOER"> <translate>Material für %{oerTitle} vorschlagen</translate> @@ -65,6 +70,7 @@ export default { userId: 'userId', consumeMode: 'consumeMode', showToolbar: 'showToolbar', + context: 'context', }), isRoot() { if (!this.structuralElement) { @@ -110,6 +116,7 @@ export default { showElementAddDialog: 'showElementAddDialog', showElementDeleteDialog: 'showElementDeleteDialog', showElementInfoDialog: 'showElementInfoDialog', + showElementLinkDialog: 'showElementLinkDialog', updateShowSuggestOerDialog: 'updateShowSuggestOerDialog', setStructuralElementSortMode: 'setStructuralElementSortMode', companionInfo: 'companionInfo', @@ -167,6 +174,9 @@ export default { suggestOER() { this.updateShowSuggestOerDialog(true); }, + linkElement() { + this.showElementLinkDialog(true); + } }, }; </script> diff --git a/resources/vue/components/courseware/CoursewareContentLinks.vue b/resources/vue/components/courseware/CoursewareContentLinks.vue new file mode 100644 index 00000000000..7260fd64010 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareContentLinks.vue @@ -0,0 +1,173 @@ +<template> + <div> + <table class="default"> + <caption> + <translate>Öffentlich verlinkte Seiten</translate> + </caption> + <thead> + <tr> + <th><translate>Seite</translate></th> + <th><translate>Link</translate></th> + <th><translate>Passwort</translate></th> + <th><translate>Ablaufdatum</translate></th> + <th class="actions"><translate>Aktionen</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="link in links" :key="link.id"> + <td><a :href="getElementUrl(link)">{{ getPage(link) }}</a></td> + <td> + <a :href="getLinkUrl(link)" target="_blank" :title="getLinkUrl(link)"> + <studip-icon shape="link-extern" role="clickable" /> + </a> + </td> + <td>{{ link.attributes.password || '-' }}</td> + <td>{{ getReadableDate(link.attributes['expire-date']) }}</td> + <td class="actions"> + <studip-action-menu + :items="menuItems" + @editLink="editLink(link)" + @deleteLink="displayDeleteLink(link)" + @copyLinkToClipboard="copyLinkToClipboard(link)" + /> + </td> + </tr> + </tbody> + </table> + <studip-dialog + v-if="showDeleteDialog" + :title="$gettext('Link löschen')" + :question="$gettext('Möchten Sie diesen Link löschen')" + height="180" + width="360" + @confirm="executeDelete" + @close="closeDeleteDialog" + ></studip-dialog> + <studip-dialog + v-if="showEditDialog" + :title="$gettext('Link bearbeiten')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + @close="closeEditDialog" + @confirm="storeLink" + > + <template v-slot:dialogContent> + <form class="default" @submit.prevent=""> + <label> + <translate>Passwort</translate> + <input type="text" v-model="currentLink.attributes.password" /> + </label> + <label> + <translate>Ablaufdatum</translate> + <input v-model="currentLink.attributes['expire-date']" type="date" class="size-l" /> + </label> + </form> + </template> + </studip-dialog> + </div> +</template> + +<script> +import StudipActionMenu from './../StudipActionMenu.vue'; +import { mapActions, mapGetters } from 'vuex'; +import StudipIcon from '../StudipIcon.vue'; + +export default { + name: 'courseware-content-links', + components: { + StudipActionMenu, + StudipIcon, + }, + data() { + return { + menuItems: [ + { id: 1, label: this.$gettext('Link in Zwischenablage kopieren'), icon: 'clipboard', emit: 'copyLinkToClipboard'}, + { id: 2, label: this.$gettext('Link bearbeiten'), icon: 'edit', emit: 'editLink' }, + { id: 3, label: this.$gettext('Link löschen'), icon: 'trash', emit: 'deleteLink' } + ], + showEditDialog: false, + showDeleteDialog: false, + currentLink: null + } + }, + computed: { + ...mapGetters({ + context: 'context', + links: 'courseware-public-links/all', + getElementById: 'courseware-structural-elements/byId', + }), + }, + methods: { + ...mapActions({ + companionSuccess: 'companionSuccess', + deleteLink: 'deleteLink', + updateLink: 'updateLink', + loadAllLinks: 'courseware-public-links/loadAll' + }), + getPage(link) { + let element = this.getElementById({ id: link.relationships['structural-element'].data.id }); + return element.attributes.title; + }, + getLinkUrl(link) { + return STUDIP.URLHelper.getURL('dispatch.php/courseware/public/', { link: link.id }); + }, + getElementUrl(link) { + return STUDIP.URLHelper.getURL('dispatch.php/contents/courseware/courseware#/structural_element/' + link.relationships['structural-element'].data.id); + }, + displayDeleteLink(link) { + this.showDeleteDialog = true; + this.currentLink = link; + }, + executeDelete() { + this.deleteLink({linkId: this.currentLink.id}); + this.closeDeleteDialog(); + }, + closeDeleteDialog() { + this.showDeleteDialog = false; + this.currentLink = null; + }, + editLink(link) { + this.showEditDialog = true; + this.currentLink = link; + }, + closeEditDialog() { + this.showEditDialog = false; + this.currentLink = null; + }, + async storeLink() { + const date = this.currentLink.attributes['expire-date']; + let attributes = { + password: this.currentLink.attributes.password, + 'expire-date': date === '' ? new Date(0).toISOString() : new Date(date).toISOString() + }; + + await this.updateLink({ + attributes: attributes, + linkId: this.currentLink.id + }); + this.closeEditDialog(); + this.companionSuccess({ + info: this.$gettext('Änderungen wurden gespeichert.'), + }); + }, + copyLinkToClipboard(link) { + navigator.clipboard.writeText(this.getLinkUrl(link)); + this.companionSuccess({ + info: this.$gettext('Link wurde in die Zwischenablage kopiert.'), + }); + }, + getReadableDate(date) { + if (!date) { + return '-'; + } + return new Date(date).toLocaleDateString(navigator.language, { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); + }, + } +} +</script> diff --git a/resources/vue/components/courseware/CoursewareDefaultBlock.vue b/resources/vue/components/courseware/CoursewareDefaultBlock.vue index 123643aea14..85e97b5e862 100755 --- a/resources/vue/components/courseware/CoursewareDefaultBlock.vue +++ b/resources/vue/components/courseware/CoursewareDefaultBlock.vue @@ -119,7 +119,7 @@ export default { defaultGrade: { type: Boolean, default: true, - } + }, }, data() { return { @@ -141,10 +141,11 @@ export default { computed: { ...mapGetters({ blockTypes: 'blockTypes', + containerById: 'courseware-containers/byId', + context: 'context', userId: 'userId', userById: 'users/byId', viewMode: 'viewMode', - containerById: 'courseware-containers/byId', }), showEditMode() { let show = this.viewMode === 'edit' || this.blockedByThisUser; @@ -187,6 +188,9 @@ export default { return this.blockTypes.find((blockType) => blockType.type === type)?.title || this.$gettext('Fehler'); }, + public() { + return this.context.type === 'public'; + } }, mounted() { if (this.blocked) { @@ -197,7 +201,7 @@ export default { this.loadUserById({ id: this.blockerId }); } } - if (this.userProgress && this.userProgress.attributes.grade === 0 && this.defaultGrade) { + if (!this.public && this.userProgress && this.userProgress.attributes.grade === 0 && this.defaultGrade) { this.userProgress = 1; } }, diff --git a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue index b65fafdd209..6a523bf8fb6 100755 --- a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue +++ b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue @@ -24,7 +24,7 @@ /> </courseware-tab> <courseware-tab - v-if="!consumeMode && showEditMode && canEdit" + v-if="displayAdder" :name="$gettext('Elemente hinzufügen')" :selected="showBlockAdder" alias="blockadder" @@ -36,7 +36,7 @@ /> </courseware-tab> <courseware-tab - v-if="!consumeMode && displaySettings" + v-if="displaySettings" :name="$gettext('Einstellungen')" :selected="showAdmin" alias="admin" @@ -80,6 +80,14 @@ export default { props: { toolsActive: Boolean, canEdit: Boolean, + disableSettings: { + type: Boolean, + default: false, + }, + disableAdder: { + type: Boolean, + default: false, + }, }, data() { return { @@ -105,9 +113,21 @@ export default { showEditMode() { return this.viewMode === 'edit'; }, + displayAdder() { + if (this.disableAdder) { + return false; + } else { + return !this.consumeMode && this.showEditMode && this.canEdit; + } + }, displaySettings() { - let user = this.userById({ id: this.userId }); - return this.context.type === 'courses' && (this.isTeacher || ['root', 'admin'].includes(user.attributes.permission)); + if (this.disableSettings) { + return false; + } else { + let user = this.userById({ id: this.userId }); + return !this.consumeMode && this.context.type === 'courses' && (this.isTeacher || ['root', 'admin'].includes(user.attributes.permission)); + } + }, isTeacher() { return this.userIsTeacher; @@ -150,7 +170,9 @@ export default { setTimeout(() => { let contents = this.$refs.contents.$el; let current = contents.querySelector('.cw-tree-item-link-current'); - contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' }); + if (current) { + contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' }); + } }, 360); }, }, diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index a1786b178c6..0c016e3cd9d 100755 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -61,6 +61,7 @@ @sortContainers="menuAction('sortContainers')" @pdfExport="menuAction('pdfExport')" @showSuggest="menuAction('showSuggest')" + @linkElement="menuAction('linkElement')" /> </template> </courseware-ribbon> @@ -558,6 +559,30 @@ @confirm="deleteCurrentElement" @close="closeDeleteDialog" ></studip-dialog> + <studip-dialog + v-if="showLinkDialog" + :title="$gettext('Öffentlichen Link für Seite erzeugen')" + :confirmText="$gettext('Erstellen')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + class="cw-structural-element-dialog" + @close="closeLinkDialog" + @confirm="createElementLink" + > + <template v-slot:dialogContent> + <form class="default" @submit.prevent=""> + <label> + <translate>Passwort</translate> + <input type="password" v-model="publicLink.password" /> + </label> + <label> + <translate>Ablaufdatum</translate> + <input v-model="publicLink['expire-date']" type="date" class="size-l" /> + </label> + </form> + </template> + </studip-dialog> </div> <div v-else> <courseware-companion-box @@ -586,6 +611,7 @@ import CoursewareTabs from './CoursewareTabs.vue'; import CoursewareTab from './CoursewareTab.vue'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import CoursewareOerMessage from '@/vue/mixins/courseware/oermessage.js'; +import CoursewareDateInput from './CoursewareDateInput.vue'; import { FocusTrap } from 'focus-trap-vue'; import IsoDate from './IsoDate.vue'; import StudipDialog from '../StudipDialog.vue'; @@ -606,6 +632,7 @@ export default { CoursewareEmptyElementBox, CoursewareTabs, CoursewareTab, + CoursewareDateInput, FocusTrap, IsoDate, StudipDialog, @@ -665,6 +692,11 @@ export default { errorEmptyChapterName: false, consumModeTrap: false, additionalText: '', + + publicLink: { + passsword: '', + 'expire-date': '' + } }; }, @@ -689,6 +721,7 @@ export default { showDeleteDialog: 'showStructuralElementDeleteDialog', showOerDialog: 'showStructuralElementOerDialog', showSuggestOerDialog: 'showSuggestOerDialog', + showLinkDialog: 'showStructuralElementLinkDialog', oerEnabled: 'oerEnabled', oerTitle: 'oerTitle', licenses: 'licenses', @@ -758,6 +791,10 @@ export default { } } + if (context.type === 'public') { + valid = true; + } + return valid; }, @@ -897,6 +934,9 @@ export default { menu.push({ id: 3, label: this.$gettext('Seite hinzufügen'), icon: 'add', emit: 'addElement' }); } + if (this.context.type === 'users') { + menu.push({ id: 6, label: this.$gettext('Öffentlichen Link erzeugen'), icon: 'group', emit: 'linkElement' }); + } if (!this.isRoot && this.canEdit && !this.isTask) { menu.push({ id: 9, @@ -1219,12 +1259,14 @@ export default { showElementInfoDialog: 'showElementInfoDialog', showElementDeleteDialog: 'showElementDeleteDialog', showElementOerDialog: 'showElementOerDialog', + showElementLinkDialog: 'showElementLinkDialog', updateShowSuggestOerDialog: 'updateShowSuggestOerDialog', updateContainer: 'updateContainer', setStructuralElementSortMode: 'setStructuralElementSortMode', sortContainersInStructualElements: 'sortContainersInStructualElements', loadTask: 'loadTask', loadStructuralElement: 'loadStructuralElement', + createLink: 'createLink', }), initCurrent() { @@ -1295,6 +1337,10 @@ export default { return false; } this.enableSortContainers(); + break; + case 'linkElement': + this.showElementLinkDialog(true); + break; } }, async closeEditDialog() { @@ -1477,6 +1523,36 @@ export default { sendOerSuggestion() { this.suggestViaAction(this.currentElement, this.additionalText); this.updateShowSuggestOerDialog(false); + }, + async createElementLink() { + const date = this.publicLink['expire-date']; + const publicLink = { + attributes: { + password: this.publicLink.password, + 'expire-date': date === '' ? new Date(0).toISOString() : new Date(date).toISOString() + }, + relationships: { + 'structural-element': { + data: { + id: this.currentElement.id, + type: 'courseware-structural-elements' + } + } + } + } + + await this.createLink({ publicLink }); + this.companionSuccess({ + info: this.$gettext('Öffentlicher Link wurde angelegt. Unter Freigaben finden Sie alle Ihre öffentlichen Links.'), + }); + this.closeLinkDialog(); + }, + closeLinkDialog() { + this.publicLink = { + passsword: '', + 'expire-date': '' + }; + this.showElementLinkDialog(false); } }, created() { diff --git a/resources/vue/components/courseware/CoursewareTree.vue b/resources/vue/components/courseware/CoursewareTree.vue index 881c1a48df7..eba9f775bc4 100755 --- a/resources/vue/components/courseware/CoursewareTree.vue +++ b/resources/vue/components/courseware/CoursewareTree.vue @@ -19,6 +19,7 @@ export default { name: 'courseware-tree', computed: { ...mapGetters({ + context: 'context', courseware: 'courseware', relatedStructuralElement: 'courseware-structural-elements/related', structuralElementById: 'courseware-structural-elements/byId', @@ -33,12 +34,17 @@ export default { }, rootElement() { - const root = this.relatedStructuralElement({ - parent: { id: this.courseware.id, type: this.courseware.type }, - relationship: 'root', - }); + if (this.context.type !== 'public') { + const root = this.relatedStructuralElement({ + parent: { id: this.courseware.id, type: this.courseware.type }, + relationship: 'root', + }); + + return root; + } else { + return this.structuralElementById({ id: this.context.rootId }); + } - return root; }, }, }; diff --git a/resources/vue/components/courseware/PublicApp.vue b/resources/vue/components/courseware/PublicApp.vue new file mode 100644 index 00000000000..616db7fa69f --- /dev/null +++ b/resources/vue/components/courseware/PublicApp.vue @@ -0,0 +1,141 @@ +<template> + <div> + <div v-if="structureLoadingState === 'done'"> + <public-courseware-structural-element + :canVisit="true" + :structural-element="selected" + :ordered-structural-elements="orderedStructuralElements" + @select="selectStructuralElement" + ></public-courseware-structural-element> + </div> + <studip-progress-indicator + v-if="structureLoadingState === 'loading'" + class="cw-loading-indicator-content" + :description="$gettext('Lade Lernmaterial...')" + /> + <courseware-companion-box + v-if="structureLoadingState === 'error'" + mood="sad" + :msgCompanion="loadingErrorMessage" + /> + <courseware-companion-box + v-if="wrongPassword" + mood="sad" + :msgCompanion="passwordMessage" + /> + <form v-if="!isAuthenticated" class="default" @submit.prevent=""> + <label> + <translate>Passwort</translate> + <input type="password" v-model="password"> + </label> + <button class="button" @click="submitPassword"> + <translate>Absenden</translate> + </button> + </form> + </div> +</template> + +<script> +import PublicCoursewareStructuralElement from './PublicCoursewareStructuralElement.vue'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import StudipProgressIndicator from '../StudipProgressIndicator.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + components: { + PublicCoursewareStructuralElement, + CoursewareCompanionBox, + StudipProgressIndicator, + }, + data() { + return { + selected: null, + structureLoadingState: 'idle', + loadingErrorStatus: null, + wrongPassword: false, + password: '', + passwordMessage: this.$gettext('Das eingegebene Passwort ist leider falsch.'), + userIsTeacher: false, + } + }, + computed: { + ...mapGetters({ + context: 'context', + courseware: 'courseware', + isAuthenticated: 'isAuthenticated', + relatedStructuralElement: 'courseware-structural-elements/related', + structuralElements: 'courseware-structural-elements/all', + structuralElementById: 'courseware-structural-elements/byId', + userId: 'userId', + + orderedStructuralElements: 'courseware-structure/ordered', + childrenById: 'courseware-structure/children', + }), + loadingErrorMessage() { + switch (this.loadingErrorStatus) { + case 404: + return this.$gettext('Die Seite konnte nicht gefunden werden.'); + case 403: + return this.$gettext('Diese Seite steht Ihnen leider nicht zur Verfügung.'); + default: + return this.$gettext('Beim Laden der Seite ist ein Fehler aufgetreten.'); + } + }, + selectedId() { + return this.$route.params?.id; + } + }, + methods: { + ...mapActions({ + loadElements: 'courseware-structural-elements/loadAll', + buildStructure: 'courseware-structure/build', + loadStructuralElement: 'loadStructuralElement', + validatePassword: 'validatePassword', + }), + async selectStructuralElement(id) { + if (!id) { + return; + } + + this.loadingErrorStatus = null; + this.structureLoadingState = 'loading'; + try { + await this.loadStructuralElement(id); + } catch(error) { + this.loadingErrorStatus = error.status; + this.structureLoadingState = 'error'; + return; + } + this.structureLoadingState = 'done'; + this.selected = this.structuralElementById({ id }); + }, + + submitPassword() { + this.validatePassword(this.password); + if (this.isAuthenticated) { + this.$router.push({ path: '/', replace: true}); + } else { + this.wrongPassword = true; + } + } + }, + async mounted() { + await this.loadElements(); + await this.buildStructure(); + const selectedId = this.$route.params?.id; + await this.selectStructuralElement(selectedId); + }, + + watch: { + $route(to) { + const selectedId = to.params?.id; + this.selectStructuralElement(selectedId); + window.scrollTo({ top: 0 }); + }, + password() { + this.wrongPassword = false; + }, + }, + +} +</script> diff --git a/resources/vue/components/courseware/PublicCoursewareStructuralElement.vue b/resources/vue/components/courseware/PublicCoursewareStructuralElement.vue new file mode 100644 index 00000000000..f6956d3cead --- /dev/null +++ b/resources/vue/components/courseware/PublicCoursewareStructuralElement.vue @@ -0,0 +1,256 @@ +<template> + <focus-trap v-model="consumModeTrap"> + <div + :class="{ 'cw-structural-element-consumemode': consumeMode }" + class="cw-structural-element" + > + <div v-if="structuralElement" class="cw-structural-element-content"> + <courseware-ribbon :canEdit="false" :disableSettings="true" :disableAdder="true"> + <template #buttons> + <router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id"> + <div class="cw-ribbon-button cw-ribbon-button-prev" :title="textRibbon.perv" /> + </router-link> + <div v-else class="cw-ribbon-button cw-ribbon-button-prev-disabled" :title="$gettext('keine vorherige Seite')"/> + <router-link v-if="nextElement" :to="'/structural_element/' + nextElement.id"> + <div class="cw-ribbon-button cw-ribbon-button-next" :title="textRibbon.next" /> + </router-link> + <div v-else class="cw-ribbon-button cw-ribbon-button-next-disabled" :title="$gettext('keine nächste Seite')"/> + </template> + <template #breadcrumbList> + <li + v-for="ancestor in ancestors" + :key="ancestor.id" + :title="ancestor.attributes.title" + class="cw-ribbon-breadcrumb-item" + > + <span> + <router-link :to="'/structural_element/' + ancestor.id">{{ ancestor.attributes.title || "–" }}</router-link> + </span> + </li> + <li + class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current" + :title="structuralElement.attributes.title" + > + <span>{{ structuralElement.attributes.title || "–" }}</span> + </li> + </template> + <template #breadcrumbFallback> + <li + class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current" + :title="structuralElement.attributes.title" + > + <span>{{ structuralElement.attributes.title }}</span> + </li> + </template> + </courseware-ribbon> + + <div + class="cw-container-wrapper" + :class="{ + 'cw-container-wrapper-consume': consumeMode, + }" + > + <div v-if="structuralElementLoaded" class="cw-companion-box-wrapper"> + <courseware-empty-element-box + v-if="noContainers" + :noContainers="noContainers" + /> + </div> + <component + v-for="container in containers" + :key="container.id" + :is="containerComponent(container)" + :container="container" + :canEdit="false" + :canAddElements="false" + :isTeacher="false" + class="cw-container-item" + /> + </div> + </div> + </div> + </focus-trap> +</template> + +<script> +import ContainerComponents from './container-components.js'; +import CoursewarePluginComponents from './plugin-components.js'; +import CoursewareAccordionContainer from './CoursewareAccordionContainer.vue'; +import CoursewareListContainer from './CoursewareListContainer.vue'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; +import CoursewareEmptyElementBox from './CoursewareEmptyElementBox.vue'; +import CoursewareTabsContainer from './CoursewareTabsContainer.vue'; +import CoursewareRibbon from './CoursewareRibbon.vue'; +import CoursewareTabs from './CoursewareTabs.vue'; +import CoursewareTab from './CoursewareTab.vue'; + +import IsoDate from './IsoDate.vue'; +import { FocusTrap } from 'focus-trap-vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'public-courseware-structural-element', + + components: { + ContainerComponents, + CoursewareRibbon, + CoursewareListContainer, + CoursewareAccordionContainer, + CoursewareTabsContainer, + CoursewareCompanionBox, + CoursewareCompanionOverlay, + CoursewareEmptyElementBox, + CoursewareTabs, + CoursewareTab, + FocusTrap, + IsoDate, + }, + + props: ['orderedStructuralElements', 'structuralElement'], + + data() { + return { + consumModeTrap: false, + textRibbon: { + perv: this.$gettext('zurück'), + next: this.$gettext('weiter'), + }, + } + }, + + computed: { + ...mapGetters({ + courseware: 'courseware', + context: 'context', + consumeMode: 'consumeMode', + containerById: 'courseware-containers/byId', + pluginManager: 'pluginManager', + relatedContainers: 'courseware-containers/related', + relatedStructuralElements: 'courseware-structural-elements/related', + structuralElementById: 'courseware-structural-elements/byId', + }), + + currentId() { + return this.structuralElement?.id; + }, + + image() { + return this.structuralElement.relationships?.image?.meta?.['download-url'] ?? null; + }, + + structuralElementLoaded() { + return this.structuralElement !== null && this.structuralElement !== {}; + }, + + ancestors() { + if (!this.structuralElement) { + return []; + } + + const finder = (parent) => { + const parentId = parent.relationships?.parent?.data?.id; + if (!parentId) { + return null; + } + const element = this.structuralElementById({ id: parentId }); + + return element; + }; + + const visitAncestors = function* (node) { + const parent = finder(node); + if (parent) { + yield parent; + yield* visitAncestors(parent); + } + }; + + return [...visitAncestors(this.structuralElement)].reverse(); + }, + + prevElement() { + const currentIndex = this.orderedStructuralElements.indexOf(this.structuralElement.id); + if (currentIndex <= 0) { + return null; + } + const previousId = this.orderedStructuralElements[currentIndex - 1]; + const previous = this.structuralElementById({ id: previousId }); + + return previous; + }, + + nextElement() { + const currentIndex = this.orderedStructuralElements.indexOf(this.structuralElement.id); + const lastIndex = this.orderedStructuralElements.length - 1; + if (currentIndex === -1 || currentIndex === lastIndex) { + return null; + } + const nextId = this.orderedStructuralElements[currentIndex + 1]; + const next = this.structuralElementById({ id: nextId }); + + return next; + }, + + empty() { + if (this.containers === null) { + return true; + } else { + return !this.containers.some((container) => container.relationships.blocks.data.length > 0); + } + }, + + containers() { + let containers = []; + let relatedContainers = this.structuralElement?.relationships?.containers?.data; + + if (relatedContainers) { + for (const container of relatedContainers) { + containers.push(this.containerById({ id: container.id})); + } + } + + return containers; + }, + + noContainers() { + if (this.containers === null) { + return true; + } else { + return this.containers.length === 0; + } + }, + + isRoot() { + return this.structuralElement.id === this.context.rootId; + }, + }, + + methods: { + ...mapActions({ + companionError: 'companionError', + companionInfo: 'companionInfo', + companionSuccess: 'companionSuccess', + }), + containerComponent(container) { + return 'courseware-' + container.attributes['container-type'] + '-container'; + }, + }, + + created() { + this.pluginManager.registerComponentsLocally(this); + }, + + watch: { + consumeMode(newState) { + this.consumModeTrap = newState; + }, + }, + + // this line provides all the components to courseware plugins + provide: () => ({ + containerComponents: ContainerComponents, + coursewarePluginComponents: CoursewarePluginComponents, + }), +}; +</script> diff --git a/resources/vue/courseware-content-releases-app.js b/resources/vue/courseware-content-releases-app.js new file mode 100644 index 00000000000..f99744b34a8 --- /dev/null +++ b/resources/vue/courseware-content-releases-app.js @@ -0,0 +1,70 @@ +import ContentReleasesApp from './components/courseware/ContentReleasesApp.vue'; +import CoursewareModule from './store/courseware/courseware.module'; +import { mapResourceModules } from '@elan-ev/reststate-vuex'; +import Vuex from 'vuex'; +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: [ + 'courseware-containers', + 'courseware-public-links', + 'courseware-structural-elements', + 'file-refs', + 'users', + ], + httpClient, + }), + }, + }); + let entry_id = null; + let entry_type = null; + let elem = document.getElementById(element.substring(1)); + + if (elem !== 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('coursewareContext', { + id: entry_id, + type: entry_type, + }); + + store.dispatch('courseware-public-links/loadAll', { + options: { + include: 'structural-element', + }, + }); + + const app = createApp({ + render: (h) => h(ContentReleasesApp), + store + }); + + app.$mount(element); + + return app; +} + +export default mountApp; \ No newline at end of file diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index acc8457c747..c2e4a1e52d8 100755 --- a/resources/vue/courseware-index-app.js +++ b/resources/vue/courseware-index-app.js @@ -95,6 +95,7 @@ const mountApp = (STUDIP, createApp, element) => { 'courseware-block-feedback', 'courseware-containers', 'courseware-instances', + 'courseware-public-links', 'courseware-structural-elements', 'courseware-structural-element-comments', 'courseware-structural-element-feedback', diff --git a/resources/vue/courseware-public-app.js b/resources/vue/courseware-public-app.js new file mode 100644 index 00000000000..ae706055b89 --- /dev/null +++ b/resources/vue/courseware-public-app.js @@ -0,0 +1,127 @@ +import PublicApp from './components/courseware/PublicApp.vue'; +import CoursewarePublicModule from './store/courseware/courseware-public.module'; +import PublicCoursewareStructuralElement from './components/courseware/PublicCoursewareStructuralElement.vue'; +import CoursewarePublicStructureModule from './store/courseware/public-structure.module'; +import PluginManager from './components/courseware/plugin-manager.js'; +import VueRouter from 'vue-router'; +import Vuex from 'vuex'; +import axios from 'axios'; +import { mapResourceModules } from '@elan-ev/reststate-vuex'; +import _ from 'lodash'; + +const mountApp = (STUDIP, createApp, element) => { + + let elem_id = null; + let link_id = null; + let link_pass = null; + let entry_type = null; + let elem = document.getElementById(element.substring(1)); + + if (elem !== undefined) { + if (elem.attributes !== undefined) { + if (elem.attributes['entry-element-id'] !== undefined) { + elem_id = elem.attributes['entry-element-id'].value; + } + + if (elem.attributes['entry-type'] !== undefined) { + entry_type = elem.attributes['entry-type'].value; + } + + if (elem.attributes['link-id'] !== undefined) { + link_id = elem.attributes['link-id'].value; + } + + if (elem.attributes['link-pass'] !== undefined) { + link_pass = elem.attributes['link-pass'].value; + } + } + } + + const getHttpClient = () => + axios.create({ + baseURL: STUDIP.URLHelper.getURL('jsonapi.php/v1/public/courseware/' + link_id, {}, true), + headers: { + 'Content-Type': 'application/vnd.api+json', + }, + }); + + let base = new URL( + STUDIP.URLHelper.getURL('dispatch.php/courseware/public', { link: link_id }, true) + ); + + const httpClient = getHttpClient(); + + const store = new Vuex.Store({ + modules: { + // courseware: CoursewareModule, + 'courseware-public': CoursewarePublicModule, + 'courseware-structure': CoursewarePublicStructureModule, + ...mapResourceModules({ + names: [ + 'courseware-blocks', + 'courseware-containers', + 'courseware-instances', + 'courseware-structural-elements', + 'courseware-user-data-fields', + 'courseware-user-progresses', + 'files', + 'file-refs', + 'folders', + 'users', + ], + httpClient, + }), + }, + }); + + store.dispatch('setContext', { + id: link_id, + type: entry_type, + rootId: elem_id + }); + + if (link_pass) { + store.dispatch('setPassword', link_pass); + } else { + store.dispatch('setIsAuthenticated', true); + } + + const pluginManager = new PluginManager(); + store.dispatch('setPluginManager', pluginManager); + STUDIP.eventBus.emit('courseware:init-plugin-manager', pluginManager); + + const routes = [ + { + path: '/', + redirect: '/structural_element/' + elem_id, + }, + { + path: '/structural_element/:id', + name: 'PublicCoursewareStructuralElement', + component: PublicCoursewareStructuralElement, + beforeEnter: (to, from, next) => { + if (!store.getters.isAuthenticated) { + return false; + } + next(); + }, + }, + ]; + + const router = new VueRouter({ + base: `${base.pathname}${base.search}`, + routes, + }); + + const app = createApp({ + render: (h) => h(PublicApp), + router, + store + }); + + app.$mount(element); + + return app; +} + +export default mountApp; diff --git a/resources/vue/store/courseware/courseware-links.module.js b/resources/vue/store/courseware/courseware-links.module.js new file mode 100644 index 00000000000..ed198d079aa --- /dev/null +++ b/resources/vue/store/courseware/courseware-links.module.js @@ -0,0 +1,29 @@ +const getDefaultState = () => { + return { + }; +}; + +const initialState = getDefaultState(); + +const getters = { + +}; + +export const state = { ...initialState }; + +export const actions = { + // setters + + // other actions +}; + +export const mutations = { + +}; + +export default { + state, + actions, + mutations, + getters, +}; diff --git a/resources/vue/store/courseware/courseware-public.module.js b/resources/vue/store/courseware/courseware-public.module.js new file mode 100644 index 00000000000..1982fe28fcf --- /dev/null +++ b/resources/vue/store/courseware/courseware-public.module.js @@ -0,0 +1,175 @@ +const getDefaultState = () => { + return { + blockAdder: {}, + consumeMode: true, + containerAdder: false, + context: null, + courseware: {}, + isAuthenticated: false, + password: null, + pluginManager: null, + selectedToolbarItem: 'contents', + showToolbar: false, + userId: null, + viewMode: 'read', + }; +}; + +const initialState = getDefaultState(); + +const getters = { + blockAdder(state) { + return state.blockAdder; + }, + + consumeMode(state) { + return state.consumeMode; + }, + + containerAdder(state) { + return state.containerAdder; + }, + + context(state) { + return state.context; + }, + + courseware(state) { + return state.courseware; + }, + + isAuthenticated(state) { + return state.isAuthenticated; + }, + + password(state) { + return state.password; + }, + + pluginManager(state) { + return state.pluginManager; + }, + + selectedToolbarItem(state) { + return state.selectedToolbarItem; + }, + + showToolbar(state) { + return state.showToolbar; + }, + + userId(state) { + return state.userId; + }, + + viewMode(state) { + return state.viewMode; + }, +}; + +export const state = { ...initialState }; + +export const actions = { + // setters + coursewareConsumeMode({ commit }, consumeMode) { + commit('setConsumeMode', consumeMode); + }, + + coursewareContainerAdder(context, adder) { + context.commit('setContainerAdder', adder); + }, + + coursewareShowToolbar(context, toolbar) { + context.commit('setShowToolbar', toolbar); + }, + + coursewareViewMode(context, view) { + context.commit('setViewMode', view); + }, + + setContext({ commit }, context) { + commit('setContext', context); + }, + + setPluginManager({ commit }, pluginManager) { + commit('setPluginManager', pluginManager); + }, + + setIsAuthenticated({ commit }, isAuthenticated) { + commit('setIsAuthenticated', isAuthenticated); + }, + + setPassword({ commit }, password) { + commit('setPassword', password); + }, + + // other actions + loadStructuralElement({ dispatch }, structuralElementId) { + const options = { + include: + 'containers,containers.blocks', + }; + + return dispatch( + 'courseware-structural-elements/loadById', + { id: structuralElementId, options }, + { root: true } + ); + }, + + validatePassword({ getters, dispatch }, password) { + if (password === getters.password) { + dispatch('setIsAuthenticated', true); + + return true; + } + + return false; + } +}; + +export const mutations = { + + coursewareSet(state, data) { + state.courseware = data; + }, + + setConsumeMode(state, consumeMode) { + state.consumeMode = consumeMode; + }, + + setContainerAdder(state, containerAdder) { + state.containerAdder = containerAdder; + }, + + setContext(state, context) { + state.context = context; + }, + + setIsAuthenticated(state, isAuthenticated) { + state.isAuthenticated = isAuthenticated; + }, + + setPassword(state, password) { + state.password = password; + }, + + setPluginManager(state, pluginManager) { + state.pluginManager = pluginManager; + }, + + setShowToolbar(state, showToolbar) { + state.showToolbar = showToolbar; + }, + + setViewMode(state, data) { + state.viewMode = data; + }, +}; + +export default { + state, + actions, + mutations, + getters, +}; diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index dd4cfac60d9..9a511100f9d 100755 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -36,6 +36,7 @@ const getDefaultState = () => { showStructuralElementInfoDialog: false, showStructuralElementDeleteDialog: false, showStructuralElementOerDialog: false, + showStructuralElementLinkDialog: false, showSuggestOerDialog: false, @@ -171,6 +172,9 @@ const getters = { showStructuralElementDeleteDialog(state) { return state.showStructuralElementDeleteDialog; }, + showStructuralElementLinkDialog(state) { + return state.showStructuralElementLinkDialog; + }, showOverviewElementAddDialog(state) { return state.showOverviewElementAddDialog; }, @@ -822,6 +826,10 @@ export const actions = { context.commit('setShowStructuralElementDeleteDialog', bool); }, + showElementLinkDialog(context, bool) { + context.commit('setShowStructuralElementLinkDialog', bool); + }, + setShowOverviewElementAddDialog(context, bool) { context.commit('setShowOverviewElementAddDialog', bool); }, @@ -1194,6 +1202,28 @@ export const actions = { setBookmarkFilter({ commit }, course) { commit('setBookmarkFilter', course); }, + + createLink({ dispatch, rootGetters }, { publicLink }) { + dispatch('courseware-public-links/create', publicLink, { root: true }); + }, + + deleteLink({ dispatch }, { linkId }) { + const data = { + id: linkId, + }; + dispatch('courseware-public-links/delete', data, { root: true }); + }, + + async updateLink({ dispatch }, { attributes, linkId }) { + const link = { + type: 'courseware-public-links', + attributes: attributes, + id: linkId, + }; + await dispatch('courseware-public-links/update', link, { root: true }); + + return dispatch('courseware-public-links/loadById', { id: link.id }, { root: true }); + } }; /* eslint no-param-reassign: ["error", { "props": false }] */ @@ -1324,6 +1354,10 @@ export const mutations = { state.showOverviewElementAddDialog = showAdd; }, + setShowStructuralElementLinkDialog(state, showLink) { + state.showStructuralElementLinkDialog = showLink; + }, + setStructuralElementSortMode(state, mode) { state.structuralElementSortMode = mode; }, diff --git a/resources/vue/store/courseware/public-structure.module.js b/resources/vue/store/courseware/public-structure.module.js new file mode 100644 index 00000000000..134a65e3596 --- /dev/null +++ b/resources/vue/store/courseware/public-structure.module.js @@ -0,0 +1,78 @@ +const getDefaultState = () => { + return { + children: [], + ordered: [], + }; +}; + +const initialState = getDefaultState(); +const state = { ...initialState }; + +const getters = { + children(state) { + return (id) => state.children[id] ?? []; + }, + ordered(state) { + return state.ordered; + }, +}; + +export const mutations = { + reset(state) { + state = getDefaultState(); + }, + setChildren(state, children) { + state.children = children; + }, + setOrdered(state, ordered) { + state.ordered = ordered; + }, +}; + +const actions = { + build({commit, rootGetters }) { + const context = rootGetters['context']; + const structuralElements = rootGetters['courseware-structural-elements/all']; + const children = structuralElements.reduce((memo, element) => { + const parent = element.relationships.parent?.data?.id ?? null; + if (parent) { + if (!memo[parent]) { + memo[parent] = []; + } + memo[parent].push([element.id, element.attributes.position]); + } + + return memo; + }, {}); + for (const key of Object.keys(children)) { + children[key].sort((childA, childB) => childA[1] - childB[1]); + children[key] = children[key].map(([id]) => id); + } + + commit('setChildren', children); + + const ordered = [...visitTree(children, context.rootId)]; + commit('setOrdered', ordered); + }, +}; + +function* visitTree(tree, current) { + if (current) { + yield current; + + const children = tree[current]; + if (children) { + for (let index = 0; index < children.length; index++) { + yield* visitTree(tree, children[index]); + } + } + } +} + +export default { + namespaced: true, + actions, + getters, + mutations, + state, +}; -- GitLab