From 5df872bbd4f3ea60530f0d2b7b58dff92e2236df Mon Sep 17 00:00:00 2001 From: hamza221 Date: Wed, 30 Nov 2022 21:17:12 +0100 Subject: [PATCH] Added the possibility to collaborate on forms Signed-off-by: hamza221 --- appinfo/routes.php | 16 +++ lib/Controller/ApiController.php | 34 +++++- lib/Controller/ShareApiController.php | 54 ++++++++- lib/Db/Share.php | 6 + .../Version3000Date20221127191108.php | 59 ++++++++++ lib/Service/FormsService.php | 106 +++++++++++++++++- src/Forms.vue | 50 +++++++-- .../SidebarTabs/SharingShareDiv.vue | 17 ++- .../SidebarTabs/SharingSidebarTab.vue | 39 ++++++- src/views/Sidebar.vue | 7 +- 10 files changed, 369 insertions(+), 19 deletions(-) create mode 100644 lib/Migration/Version3000Date20221127191108.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 8ebe6478b..578f68b34 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -131,6 +131,14 @@ 'apiVersion' => 'v2' ] ], + [ + 'name' => 'api#getCollaborationForms', + 'url' => '/api/{apiVersion}/collaboration_forms', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => 'v2' + ] + ], // Questions [ @@ -209,6 +217,14 @@ 'apiVersion' => 'v2' ] ], + [ + 'name' => 'shareApi#toggleEditor', + 'url' => '/api/{apiVersion}/share/toggleEditor', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v2' + ] + ], // Submissions [ diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 9706b1970..adcbb4fd2 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -182,6 +182,28 @@ public function getSharedForms(): DataResponse { return new DataResponse($result); } + /** + * @NoAdminRequired + * + * Read List of forms shared with current user + * Return only with necessary information and editing enabled for Listing. + * @return DataResponse + */ + public function getCollaborationForms(): DataResponse { + $forms = $this->formMapper->findAll(); + + $result = []; + foreach ($forms as $form) { + // Check if the form should be shown on sidebar + if (!$this->formsService->isSharedCollaborationFormShown($form->getId())) { + continue; + } + $result[] = $this->formsService->getPartialFormArray($form->getId()); + } + + return new DataResponse($result); + } + /** * @NoAdminRequired * @@ -363,7 +385,7 @@ public function updateForm(int $id, array $keyValuePairs): DataResponse { throw new OCSBadRequestException(); } - if ($form->getOwnerId() !== $this->currentUser->getUID()) { + if (!$this->formsService->isAllowedToEdit($form->getId())) { $this->logger->debug('This form is not owned by the current user'); throw new OCSForbiddenException(); } @@ -460,7 +482,7 @@ public function newQuestion(int $formId, string $type, string $text = ''): DataR throw new OCSBadRequestException(); } - if ($form->getOwnerId() !== $this->currentUser->getUID()) { + if (!$this->formsService->isAllowedToEdit($form->getId())) { $this->logger->debug('This form is not owned by the current user'); throw new OCSForbiddenException(); } @@ -610,7 +632,7 @@ public function updateQuestion(int $id, array $keyValuePairs): DataResponse { throw new OCSBadRequestException('Could not find form or question'); } - if ($form->getOwnerId() !== $this->currentUser->getUID()) { + if (!$this->formsService->isAllowedToEdit($form->getId())) { $this->logger->debug('This form is not owned by the current user'); throw new OCSForbiddenException(); } @@ -716,7 +738,7 @@ public function newOption(int $questionId, string $text): DataResponse { throw new OCSBadRequestException('Could not find form or question'); } - if ($form->getOwnerId() !== $this->currentUser->getUID()) { + if (!$this->formsService->isAllowedToEdit($form->getId())) { $this->logger->debug('This form is not owned by the current user'); throw new OCSForbiddenException(); } @@ -757,7 +779,7 @@ public function updateOption(int $id, array $keyValuePairs): DataResponse { throw new OCSBadRequestException('Could not find option, question or form'); } - if ($form->getOwnerId() !== $this->currentUser->getUID()) { + if (!$this->formsService->isAllowedToEdit($form->getId())) { $this->logger->debug('This form is not owned by the current user'); throw new OCSForbiddenException(); } @@ -836,7 +858,7 @@ public function getSubmissions(string $hash): DataResponse { throw new OCSBadRequestException(); } - if ($form->getOwnerId() !== $this->currentUser->getUID()) { + if (!$this->formsService->isAllowedToEdit($form->getId())) { $this->logger->debug('This form is not owned by the current user'); throw new OCSForbiddenException(); } diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index f9961e330..57bc3bb69 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -146,7 +146,7 @@ public function newShare(int $formId, int $shareType, string $shareWith = ''): D } // Check for permission to share form - if ($form->getOwnerId() !== $this->currentUser->getUID()) { + if (!$this->formsService->isAllowedToEdit($formId)) { $this->logger->debug('This form is not owned by the current user'); throw new OCSForbiddenException(); } @@ -244,4 +244,56 @@ public function deleteShare(int $id): DataResponse { return new DataResponse($id); } + + /** + * @NoAdminRequired + * + * toggle editor role in shares + * + * @param int $id of the share to update + * @param bool $isEditor state of the editor role + * @param bool $uid id of the shared with user + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function toggleEditor(int $formId, bool $isEditor, string $uid): DataResponse { + $this->logger->debug('updating editor role in share: {id} to {isEditor} for user: {uid}', [ + 'id' => $id, + 'isEditor' => $isEditor, + 'uid' => $uid + ]); + $shareId = $this->formsService->getShareByFromIdAndUserid($formId, $uid); + if ($shareId < 0) { + $shareData = $this->newShare($formId, IShare::TYPE_USER, $uid); + if ($isEditor) { + $share = Share::fromParams($shareData); + $share->setIsEditor($isEditor); + $this->shareMapper->update($share); + } + return new DataResponse($share->getId()); + } else { + try { + $share = $this->shareMapper->findById($shareId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find share', ['exception' => $e]); + throw new OCSBadRequestException('Could not find share'); + } + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if ($share->getIsEditor() !== $isEditor) { + $share->setIsEditor($isEditor); + $this->shareMapper->update($share); + return new DataResponse($share->getId()); + } + $this->logger->debug('Share is already in the required state.'); + + return new DataResponse($share->getId()); + } } diff --git a/lib/Db/Share.php b/lib/Db/Share.php index c4861499d..e438809eb 100644 --- a/lib/Db/Share.php +++ b/lib/Db/Share.php @@ -35,6 +35,8 @@ * @method void setShareType(integer $value) * @method string getShareWith() * @method void setShareWith(string $value) + * @method string getIsEditor() + * @method void setIsEditor(bool $value) */ class Share extends Entity { /** @var int */ @@ -43,6 +45,8 @@ class Share extends Entity { protected $shareType; /** @var string */ protected $shareWith; + /** @var bool */ + protected $isEditor; /** * Option constructor. @@ -51,6 +55,7 @@ public function __construct() { $this->addType('formId', 'integer'); $this->addType('shareType', 'integer'); $this->addType('shareWith', 'string'); + $this->addType('isEditor', 'bool'); } public function read(): array { @@ -59,6 +64,7 @@ public function read(): array { 'formId' => $this->getFormId(), 'shareType' => $this->getShareType(), 'shareWith' => $this->getShareWith(), + 'isEditor' => $this->getIsEditor(), ]; } } diff --git a/lib/Migration/Version3000Date20221127191108.php b/lib/Migration/Version3000Date20221127191108.php new file mode 100644 index 000000000..0493b16e0 --- /dev/null +++ b/lib/Migration/Version3000Date20221127191108.php @@ -0,0 +1,59 @@ + + * + * @author Hamza Mahjoubi + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Forms\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version3000Date20221127191108 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + $table = $schema->getTable('forms_v2_shares'); + + if (!$table->hasColumn('is_editor')) { + $table->addColumn('is_editor', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => 0, + ]); + + return $schema; + } + + return null; + } +} diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index d0433c912..6d7f756c0 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -271,11 +271,22 @@ public function getPermissions(int $formId): array { } $permissions = []; + + if ($this->isSharedToUserForCollaboration($formId)) { + $permissions[] = Constants::PERMISSION_EDIT; + $permissions[] = Constants::PERMISSION_RESULTS; + $permissions[] = Constants::PERMISSION_SUBMIT; + + return $permissions; + } + // Add submit permission if user has access. if ($this->hasUserAccess($formId)) { $permissions[] = Constants::PERMISSION_SUBMIT; } + + return $permissions; } @@ -365,6 +376,9 @@ public function hasUserAccess(int $formId): bool { if ($this->isSharedToUser($formId)) { return true; } + if ($this->isSharedToUserForCollaboration($formId)) { + return true; + } // None of the possible access-options matched. return false; @@ -384,6 +398,10 @@ public function isSharedFormShown(int $formId): bool { if ($form->getOwnerId() === $this->currentUser->getUID()) { return false; } + //don't show forms share for collaboration + if ($this->isSharedCollaborationFormShown($formId)) { + return false; + } // Dont show expired forms. if ($this->hasFormExpired($form->getId())) { @@ -405,7 +423,55 @@ public function isSharedFormShown(int $formId): bool { // No Reason found to show form. return false; } + /** + * Is the form shown on sidebar for collaboration to the user. + * + * @param int $formId + * @return bool + */ + public function isSharedCollaborationFormShown(int $formId): bool { + $form = $this->formMapper->findById($formId); + + // Dont show here to owner, as its in the owned list anyways. + if ($form->getOwnerId() === $this->currentUser->getUID()) { + return false; + } + + // Dont show expired forms. + if ($this->hasFormExpired($form->getId())) { + return false; + } + + // Shown if user in List of Shared Users/Groups + if ($this->isSharedToUserForCollaboration($formId)) { + return true; + } + + // No Reason found to show form. + return false; + } + /** + * Is user allowed to edit a form and its components. + * + * @param int $formId + * @return bool + */ + public function isAllowedToEdit(int $formId): bool { + $form = $this->formMapper->findById($formId); + + // Form owner can edit it. + if ($form->getOwnerId() === $this->currentUser->getUID()) { + return true; + } + // A collaborator can also edit and its components + if ($this->isSharedToUserForCollaboration($formId)) { + return true; + } + + // No Reason found to allwow form edit. + return false; + } /** * Checking all selected shares * @@ -420,7 +486,7 @@ public function isSharedToUser(int $formId): bool { // Needs different handling for shareTypes switch ($share['shareType']) { case IShare::TYPE_USER: - if ($share['shareWith'] === $this->currentUser->getUID()) { + if ($share['shareWith'] === $this->currentUser->getUID() && !$share['isEditor']) { return true; } break; @@ -437,6 +503,44 @@ public function isSharedToUser(int $formId): bool { // No share found. return false; } + /** + * Checking all selected shares + * + * @param $formId + * @return bool + */ + public function isSharedToUserForCollaboration(int $formId): bool { + $shareEntities = $this->shareMapper->findByForm($formId); + foreach ($shareEntities as $shareEntity) { + $share = $shareEntity->read(); + // if share type is user and the form is share to current user with editor privileges return true + if ($share['isEditor'] && $share['shareType'] === IShare::TYPE_USER && $share['shareWith'] === $this->currentUser->getUID()) { + return true; + } + } + // No share found. + return false; + } + + /** + * get Share id from form id and user id + * + * @param $formId + * @param $uid id of the user + * @return int + */ + public function getShareByFromIdAndUserid(int $formId, string $uid): int { + $shareEntities = $this->shareMapper->findByForm($formId); + foreach ($shareEntities as $shareEntity) { + $share = $shareEntity->read(); + // if share type is user and the form is share to current user return the share id + if ($share['shareType'] === IShare::TYPE_USER && $share['shareWith'] === $uid) { + return $share['id']; + } + } + // No share found. + return -1; + } /* * Has the form expired? diff --git a/src/Forms.vue b/src/Forms.vue index fe9393ac2..2edd4bfad 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -43,6 +43,14 @@ @clone="onCloneForm" @delete="onDeleteForm" /> + + + + @@ -152,14 +161,14 @@ export default { sidebarActive: 'forms-sharing', forms: [], sharedForms: [], - + collaborationForms: [], canCreateForms: loadState(appName, 'appConfig').canCreateForms, } }, computed: { hasForms() { - return !this.noOwnedForms || !this.noSharedForms + return !this.noOwnedForms || !this.noSharedForms || !this.noCollaborationForms }, noOwnedForms() { return this.forms?.length === 0 @@ -167,10 +176,17 @@ export default { noSharedForms() { return this.sharedForms?.length === 0 }, + noCollaborationForms() { + return this.collaborationForms?.length === 0 + }, routeHash() { return this.$route.params.hash }, + isOwned() { + const index = this.forms.findIndex(search => search.hash === this.routeHash) + return index > -1 + }, // If the user is allowed to access this route routeAllowed() { @@ -179,8 +195,8 @@ export default { return false } - // Try to find form in owned & shared list - const form = [...this.forms, ...this.sharedForms] + // Try to find form in owned & shared & collaboration list + const form = [...this.forms, ...this.sharedForms, ...this.collaborationForms] .find(form => form.hash === this.routeHash) // If no form found, load it from server. Route will be automatically re-evaluated. @@ -188,7 +204,6 @@ export default { this.fetchPartialForm(this.routeHash) return false } - // Return whether route is in the permissions-list return form?.permissions.includes(this.$route.name) }, @@ -196,7 +211,7 @@ export default { selectedForm: { get() { if (this.routeAllowed) { - return this.forms.concat(this.sharedForms).find(form => form.hash === this.routeHash) + return this.forms.concat(this.sharedForms).concat(this.collaborationForms).find(form => form.hash === this.routeHash) } return {} }, @@ -207,11 +222,16 @@ export default { this.$set(this.forms, index, form) return } - // Otherwise a shared form + // if a shared form index = this.sharedForms.findIndex(search => search.hash === this.routeHash) if (index > -1) { this.$set(this.sharedForms, index, form) } + // otherwise a collaboration form + index = this.collaborationForms.findIndex(search => search.hash === this.routeHash) + if (index > -1) { + this.$set(this.collaborationForms, index, form) + } }, }, }, @@ -267,6 +287,15 @@ export default { showError(t('forms', 'An error occurred while loading the forms list')) } + // Load Collaboration froms + try { + const response = await axios.get(generateOcsUrl('apps/forms/api/v2/collaboration_forms')) + this.collaborationForms = OcsResponse2Data(response) + } catch (error) { + logger.error('Error while loading collaboration forms list', { error }) + showError(t('forms', 'An error occurred while loading the forms list')) + } + this.loading = false }, @@ -284,7 +313,12 @@ export default { // If the user has (at least) submission-permissions, add it to the shared forms if (form.permissions.includes(this.PERMISSION_TYPES.PERMISSION_SUBMIT)) { - this.sharedForms.push(form) + const collaborationIds = this.collaborationForms.map(collabForm => { + return collabForm.id + }) + if (!collaborationIds.includes(form.id)) { + this.sharedForms.push(form) + } } } catch (error) { logger.error(`Form ${hash} not found`, { error }) diff --git a/src/components/SidebarTabs/SharingShareDiv.vue b/src/components/SidebarTabs/SharingShareDiv.vue index 4db79b18f..38456faf3 100644 --- a/src/components/SidebarTabs/SharingShareDiv.vue +++ b/src/components/SidebarTabs/SharingShareDiv.vue @@ -30,11 +30,17 @@ {{ displayNameAppendix }} @@ -44,6 +50,7 @@ import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' +import NcActionRadio from '@nextcloud/vue/dist/Components/NcActionRadio.js' import IconClose from 'vue-material-design-icons/Close.vue' import ShareTypes from '../../mixins/ShareTypes.js' @@ -53,6 +60,7 @@ export default { IconClose, NcActions, NcActionButton, + NcActionRadio, NcAvatar, }, @@ -81,6 +89,13 @@ export default { removeShare() { this.$emit('remove-share', this.share) }, + setResponder() { + this.$emit('set-responder', this.share) + }, + setEditor() { + this.$emit('set-editor', this.share) + }, + }, } diff --git a/src/components/SidebarTabs/SharingSidebarTab.vue b/src/components/SidebarTabs/SharingSidebarTab.vue index 548c2a293..8d6897abd 100644 --- a/src/components/SidebarTabs/SharingSidebarTab.vue +++ b/src/components/SidebarTabs/SharingSidebarTab.vue @@ -152,7 +152,9 @@ + @remove-share="removeShare" + @set-responder="setResponder" + @set-editor="setEditor" /> @@ -296,6 +298,41 @@ export default { this.isLoading = false } }, + /** + * + * set as Responder + * + * @param {object} share the share of the user to set as responder + */ + async setResponder(share) { + try { + await axios.post(generateOcsUrl('apps/forms/api/v2/share/toggleEditor'), { + formId: this.form.id, + isEditor: false, + uid: share.shareWith, + }) + } catch (error) { + logger.error('Error while setting share as responder', { error, share }) + showError(t('forms', 'There while setting share as responder')) + } + }, + /** + * set as Responder + * + * @param {object} share the share of the user to set as an editor + */ + async setEditor(share) { + try { + await axios.post(generateOcsUrl('apps/forms/api/v2/share/toggleEditor'), { + formId: this.form.id, + isEditor: true, + uid: share.shareWith, + }) + } catch (error) { + logger.error('Error while setting share as responder', { error, share }) + showError(t('forms', 'There while setting share as responder')) + } + }, /** * Sort by shareType and DisplayName diff --git a/src/views/Sidebar.vue b/src/views/Sidebar.vue index de9312da5..e1560baa7 100644 --- a/src/views/Sidebar.vue +++ b/src/views/Sidebar.vue @@ -38,7 +38,8 @@ @remove-share="onRemoveShare" /> -