diff --git a/docs/API.md b/docs/API.md index 99f97de5d..86f72b430 100644 --- a/docs/API.md +++ b/docs/API.md @@ -58,7 +58,8 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "results", "submit" ], - "partial": true + "partial": true, + "state": 0 }, { "id": 3, @@ -70,7 +71,8 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "results", "submit" ], - "partial": true + "partial": true, + "state": 0 } ] ``` @@ -103,7 +105,8 @@ Returns a single partial form object, corresponding to owned/shared form-listing "permissions": [ "submit" ], - "partial": true + "partial": true, + "state": 0 } ``` @@ -146,6 +149,7 @@ Returns the full-depth object of the requested form (without submissions). "submitMultiple": true, "showExpiration": false, "canSubmit": true, + "state": 0, "permissions": [ "edit", "results", diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 938f8874f..d7a975771 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -17,6 +17,7 @@ This document describes the Object-Structure, that is used within the Forms App | access | [Access-Object](#access-object) | | Describing access-settings of the form | | expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ | | isAnonymous | Boolean | | If Answers will be stored anonymously | +| state | Integer | [Form state](#form-state)| The state of the form | | submitMultiple | Boolean | | If users are allowed to submit multiple times to the form | | showExpiration | Boolean | | If the expiration date will be shown on the form | | canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. | @@ -45,11 +46,21 @@ This document describes the Object-Structure, that is used within the Forms App "submit" ], "questions": [], - "submissions": [], + "state": 0, "shares": [] + "submissions": [], } ``` +#### Form state +The form state is used for additional states, currently following states are defined: + +| Value | Meaning | +|----------------|---------------------------------------------| +| 0 | Form is active and open for new submissions | +| 1 | Form is closed and does not allow new submissions | +| 2 | Form is archived, it does not allow new submissions and can also not be modified anymore | + ### Question | Property | Type | Restrictions | Description | |----------------|-----------------|--------------|-------------| diff --git a/lib/Constants.php b/lib/Constants.php index 00ee60516..e07ea1360 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -53,6 +53,13 @@ class Constants { 'answerText' => 4096, ]; + /** + * State flags of a form + */ + public const FORM_STATE_ACTIVE = 0; + public const FORM_STATE_CLOSED = 1; + public const FORM_STATE_ARCHIVED = 2; + /** * !! Keep in sync with src/models/AnswerTypes.js !! */ diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 48c9fbad2..173c1e9c7 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -3,6 +3,7 @@ * @copyright Copyright (c) 2017 Vinzenz Rosenkranz * * @author affan98 + * @author Ferdinand Thiessen * @author Jan-Christoph Borchardt * @author John Molakvoæ (skjnldsv) * @author Jonas Rittershofer @@ -471,6 +472,11 @@ public function newQuestion(int $formId, string $type, string $text = ''): DataR throw new OCSForbiddenException(); } + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + // Retrieve all active questions sorted by Order. Takes the order of the last array-element and adds one. $questions = $this->questionMapper->findByForm($formId); $lastQuestion = array_pop($questions); @@ -530,6 +536,11 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse { throw new OCSForbiddenException(); } + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + // Check if array contains duplicates if (array_unique($newOrder) !== $newOrder) { $this->logger->debug('The given Array contains duplicates'); @@ -627,6 +638,11 @@ public function updateQuestion(int $id, array $keyValuePairs): DataResponse { throw new OCSForbiddenException(); } + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + // Don't allow empty array if (sizeof($keyValuePairs) === 0) { $this->logger->info('Empty keyValuePairs, will not update.'); @@ -690,6 +706,11 @@ public function deleteQuestion(int $id): DataResponse { throw new OCSForbiddenException(); } + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + // Store Order of deleted Question $deletedOrder = $question->getOrder(); @@ -741,6 +762,11 @@ public function cloneQuestion(int $id): DataResponse { throw new OCSForbiddenException(); } + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + $allQuestions = $this->questionMapper->findByForm($form->getId()); $questionData = $sourceQuestion->read(); @@ -797,6 +823,11 @@ public function newOption(int $questionId, string $text): DataResponse { throw new OCSForbiddenException(); } + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + $option = new Option(); $option->setQuestionId($questionId); @@ -841,6 +872,11 @@ public function updateOption(int $id, array $keyValuePairs): DataResponse { throw new OCSForbiddenException(); } + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + // Don't allow empty array if (sizeof($keyValuePairs) === 0) { $this->logger->info('Empty keyValuePairs, will not update.'); @@ -895,6 +931,11 @@ public function deleteOption(int $id): DataResponse { throw new OCSForbiddenException(); } + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + $this->optionMapper->delete($option); $this->formsService->setLastUpdatedTimestamp($form->getId()); @@ -1180,8 +1221,9 @@ public function deleteAllSubmissions(int $formId): DataResponse { throw new OCSBadRequestException(); } - if ($form->getOwnerId() !== $this->currentUser->getUID()) { - $this->logger->debug('This form is not owned by the current user'); + // The current user has permissions to remove submissions + if (!$this->formsService->canDeleteResults($form)) { + $this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission'); throw new OCSForbiddenException(); } diff --git a/lib/Db/Form.php b/lib/Db/Form.php index 58eaac3bf..5eae019d6 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -7,6 +7,7 @@ * * @author affan98 * @author Jonas Rittershofer + * @author Ferdinand Thiessen * * @license AGPL-3.0-or-later * @@ -44,20 +45,22 @@ * @method void setFileFormat(string|null $value) * @method array getAccess() * @method void setAccess(array $value) - * @method integer getCreated() - * @method void setCreated(integer $value) - * @method integer getExpires() - * @method void setExpires(integer $value) - * @method integer getIsAnonymous() + * @method int getCreated() + * @method void setCreated(int $value) + * @method int getExpires() + * @method void setExpires(int $value) + * @method int getIsAnonymous() * @method void setIsAnonymous(bool $value) - * @method integer getSubmitMultiple() + * @method int getSubmitMultiple() * @method void setSubmitMultiple(bool $value) - * @method integer getShowExpiration() + * @method int getShowExpiration() * @method void setShowExpiration(bool $value) - * @method integer getLastUpdated() - * @method void setLastUpdated(integer $value) + * @method int getLastUpdated() + * @method void setLastUpdated(int $value) * @method ?string getSubmissionMessage() * @method void setSubmissionMessage(?string $value) + * @method int getState() + * @method void setState(?int $value) */ class Form extends Entity { protected $hash; @@ -74,6 +77,7 @@ class Form extends Entity { protected $showExpiration; protected $submissionMessage; protected $lastUpdated; + protected $state; /** * Form constructor. @@ -85,6 +89,7 @@ public function __construct() { $this->addType('submitMultiple', 'bool'); $this->addType('showExpiration', 'bool'); $this->addType('lastUpdated', 'integer'); + $this->addType('state', 'integer'); } // JSON-Decoding of access-column. @@ -115,6 +120,7 @@ public function read() { 'showExpiration' => (bool)$this->getShowExpiration(), 'lastUpdated' => (int)$this->getLastUpdated(), 'submissionMessage' => $this->getSubmissionMessage(), + 'state' => $this->getState(), ]; } } diff --git a/lib/Migration/Version040200Date20240219201500.php b/lib/Migration/Version040200Date20240219201500.php new file mode 100644 index 000000000..1c64c331f --- /dev/null +++ b/lib/Migration/Version040200Date20240219201500.php @@ -0,0 +1,78 @@ + + * + * @author Ferdinand Thiessen + * + * @license AGPL-3.0-or-later + * + * 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\QueryBuilder\IQueryBuilder; +use OCP\DB\Types; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * This migration adds the `state` property to forms for closed, archived or active forms + */ +class Version040200Date20240219201500 extends SimpleMigrationStep { + + public function __construct( + protected IDBConnection $db, + ) { + } + + /** + * @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_forms'); + + if (!$table->hasColumn('state')) { + $table->addColumn('state', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + } + + return $schema; + } + + /** + * Set all old forms to active state + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options) { + $query = $this->db->getQueryBuilder(); + $query->update('forms_v2_forms') + ->set('state', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ->executeStatement(); + } +} diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 3ad49d211..49c01d878 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -193,7 +193,8 @@ public function getPartialFormArray(Form $form): array { 'expires' => $form->getExpires(), 'lastUpdated' => $form->getLastUpdated(), 'permissions' => $this->getPermissions($form), - 'partial' => true + 'partial' => true, + 'state' => $form->getState(), ]; // Append submissionCount if currentUser has permissions to see results @@ -285,7 +286,13 @@ public function canSeeResults(Form $form): bool { * @return boolean */ public function canDeleteResults(Form $form): bool { - return in_array(Constants::PERMISSION_RESULTS_DELETE, $this->getPermissions($form)); + // Check permissions + if (!in_array(Constants::PERMISSION_RESULTS_DELETE, $this->getPermissions($form))) { + return false; + } + + // Do not allow deleting results on archived forms + return !$this->isFormArchived($form); } /** @@ -382,8 +389,6 @@ public function hasUserAccess(Form $form): bool { * @return bool */ public function isSharedFormShown(Form $form): bool { - $access = $form->getAccess(); - // Dont show here to owner, as its in the owned list anyways. if ($form->getOwnerId() === $this->currentUser->getUID()) { return false; @@ -394,6 +399,7 @@ public function isSharedFormShown(Form $form): bool { return false; } + $access = $form->getAccess(); // Shown if permitall and showntoall are both set. if ($access['permitAllUsers'] && $access['showToAllUsers'] && @@ -421,13 +427,25 @@ public function isSharedToUser(int $formId): bool { return count($shareEntities) > 0; } - /* - * Has the form expired? + /** + * Check if the form is archived + * If a form is archived no changes are allowed + */ + public function isFormArchived(Form $form): bool { + return $form->getState() === Constants::FORM_STATE_ARCHIVED; + } + + /** + * Check if the form was closed or archived or has expired. * * @param Form $form * @return boolean */ public function hasFormExpired(Form $form): bool { + // Check for form state first + if ($form->getState() !== Constants::FORM_STATE_ACTIVE) { + return true; + } return ($form->getExpires() !== 0 && $form->getExpires() < time()); } diff --git a/src/Forms.vue b/src/Forms.vue index a794c3c96..e33270d76 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -33,8 +33,8 @@ + @@ -102,6 +116,9 @@ :active.sync="sidebarActive" name="sidebar" /> + + + @@ -123,20 +140,25 @@ import NcContent from '@nextcloud/vue/dist/Components/NcContent.js' import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import IconArchive from 'vue-material-design-icons/Archive.vue' import IconPlus from 'vue-material-design-icons/Plus.vue' -import FormsIcon from './components/Icons/FormsIcon.vue' +import ArchivedFormsModal from './components/ArchivedFormsModal.vue' import AppNavigationForm from './components/AppNavigationForm.vue' +import FormsIcon from './components/Icons/FormsIcon.vue' import PermissionTypes from './mixins/PermissionTypes.js' import OcsResponse2Data from './utils/OcsResponse2Data.js' import logger from './utils/Logger.js' +import { FormState } from './models/FormStates.ts' export default { name: 'Forms', components: { AppNavigationForm, + ArchivedFormsModal, FormsIcon, + IconArchive, IconPlus, NcAppContent, NcAppNavigation, @@ -162,7 +184,9 @@ export default { sidebarOpened: false, sidebarActive: 'forms-sharing', forms: [], - sharedForms: [], + allSharedForms: [], + + showArchivedForms: false, canCreateForms: loadState(appName, 'appConfig').canCreateForms, } @@ -172,14 +196,41 @@ export default { canEdit() { return this.selectedForm.permissions.includes(this.PERMISSION_TYPES.PERMISSION_EDIT) }, + hasForms() { - return !this.noOwnedForms || !this.noSharedForms + return this.allSharedForms.length > 0 || this.forms.length > 0 }, - noOwnedForms() { - return this.forms?.length === 0 + + /** + * All own active forms + */ + ownedForms() { + const now = Date.now() / 1000 + + return this.forms + .filter((form) => form.state !== FormState.FormArchived) + .filter((form) => form.expires === 0 || form.expires >= now) + }, + + /** + * All active shared forms + */ + sharedForms() { + const now = Date.now() / 1000 + + return this.allSharedForms + .filter((form) => form.state !== FormState.FormArchived) + .filter((form) => form.expires === 0 || form.expires >= now) }, - noSharedForms() { - return this.sharedForms?.length === 0 + + /** + * All forms that have been archived + */ + archivedForms() { + return [ + ...this.forms, + ...this.allSharedForms, + ].filter((form) => form.state === FormState.FormArchived) }, routeHash() { @@ -194,7 +245,7 @@ export default { } // Try to find form in owned & shared list - const form = [...this.forms, ...this.sharedForms] + const form = [...this.forms, ...this.allSharedForms] .find(form => form.hash === this.routeHash) // If no form found, load it from server. Route will be automatically re-evaluated. @@ -210,7 +261,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.allSharedForms).find(form => form.hash === this.routeHash) } return {} }, @@ -222,9 +273,9 @@ export default { return } // Otherwise a shared form - index = this.sharedForms.findIndex(search => search.hash === this.routeHash) + index = this.allSharedForms.findIndex(search => search.hash === this.routeHash) if (index > -1) { - this.$set(this.sharedForms, index, form) + this.$set(this.allSharedForms, index, form) } }, }, @@ -285,7 +336,7 @@ export default { // Load shared forms try { const response = await axios.get(generateOcsUrl('apps/forms/api/v2.4/shared_forms')) - this.sharedForms = OcsResponse2Data(response) + this.allSharedForms = OcsResponse2Data(response) } catch (error) { logger.error('Error while loading shared forms list', { error }) showError(t('forms', 'An error occurred while loading the forms list')) @@ -300,22 +351,34 @@ export default { * @param {string} hash the hash of the form to load */ async fetchPartialForm(hash) { - this.loading = true - - try { - const response = await axios.get(generateOcsUrl('apps/forms/api/v2.4/partial_form/{hash}', { hash })) - const form = OcsResponse2Data(response) + await new Promise((resolve) => { + const wait = () => { + if (this.loading) { + window.setTimeout(wait, 250) + } else { + resolve() + } + } + wait() + }) - // 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) + this.loading = true + if ([...this.forms, ...this.allSharedForms].find((form) => form.hash === hash) === undefined) { + try { + const response = await axios.get(generateOcsUrl('apps/forms/api/v2.4/partial_form/{hash}', { hash })) + const form = OcsResponse2Data(response) + + // If the user has (at least) submission-permissions, add it to the shared forms + if (form.permissions.includes(this.PERMISSION_TYPES.PERMISSION_SUBMIT)) { + this.allSharedForms.push(form) + } + } catch (error) { + logger.error(`Form ${hash} not found`, { error }) + showError(t('forms', 'Form not found')) } - } catch (error) { - logger.error(`Form ${hash} not found`, { error }) - showError(t('forms', 'Form not found')) - } finally { - this.loading = false } + + this.loading = false }, /** @@ -381,9 +444,9 @@ export default { this.forms[formIndex].lastUpdated = moment().unix() this.forms.sort((b, a) => a.lastUpdated - b.lastUpdated) } else { - const sharedFormIndex = this.sharedForms.findIndex(form => form.id === id) - this.sharedForms[sharedFormIndex].lastUpdated = moment().unix() - this.sharedForms.sort((b, a) => a.lastUpdated - b.lastUpdated) + const sharedFormIndex = this.allSharedForms.findIndex(form => form.id === id) + this.allSharedForms[sharedFormIndex].lastUpdated = moment().unix() + this.allSharedForms.sort((b, a) => a.lastUpdated - b.lastUpdated) } }, }, @@ -391,6 +454,13 @@ export default { diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index e6ca684f9..7c39a0e30 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -3,6 +3,7 @@ - - @author John Molakvoæ - @author Jonas Rittershofer + - @author Ferdinand Thiessen - - @license AGPL-3.0-or-later - @@ -24,6 +25,7 @@