diff --git a/appinfo/routes.php b/appinfo/routes.php index f93510885..a638a52c0 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -331,6 +331,14 @@ 'apiVersion' => 'v2(\.[1-3])?' ] ], + [ + 'name' => 'api#updateSubmission', + 'url' => '/api/{apiVersion}/submission/update', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v2(\.1)?' + ] + ], [ 'name' => 'api#deleteSubmission', 'url' => '/api/{apiVersion}/submission/{id}', diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 938f8874f..0ec488f5f 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -18,6 +18,7 @@ This document describes the Object-Structure, that is used within the Forms App | expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ | | isAnonymous | Boolean | | If Answers will be stored anonymously | | submitMultiple | Boolean | | If users are allowed to submit multiple times to the form | +| allowEdit | Boolean | | If users are allowed to edit or delete their response | | 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. | | permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form | @@ -37,6 +38,7 @@ This document describes the Object-Structure, that is used within the Forms App "expires": 0, "isAnonymous": false, "submitMultiple": true, + "allowEdit": false, "showExpiration": false, "canSubmit": true, "permissions": [ diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 4df9116bc..5ca595fef 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -27,6 +27,7 @@ namespace OCA\Forms\Controller; +use OCA\Forms\Activity\ActivityManager; use OCA\Forms\Constants; use OCA\Forms\Db\Answer; use OCA\Forms\Db\AnswerMapper; @@ -70,6 +71,7 @@ public function __construct( string $appName, IRequest $request, IUserSession $userSession, + private ActivityManager $activityManager, private AnswerMapper $answerMapper, private FormMapper $formMapper, private OptionMapper $optionMapper, @@ -212,6 +214,7 @@ public function newForm(): DataResponse { 'showToAllUsers' => false, ]); $form->setSubmitMultiple(false); + $form->setAllowEdit(false); $form->setShowExpiration(false); $form->setExpires(0); $form->setIsAnonymous(false); @@ -958,19 +961,37 @@ public function getSubmissions(string $hash): DataResponse { return new DataResponse($response); } + /** - * Insert answers for a question + * @CORS + * @PublicCORSFix + * @NoAdminRequired + * @PublicPage + * Insert or update answers for a question * * @param int $submissionId * @param array $question * @param array $answerArray [arrayOfString] + * @param bool $update */ - private function storeAnswersForQuestion($submissionId, array $question, array $answerArray) { - foreach ($answerArray as $answer) { - $answerText = ''; + private function storeAnswersForQuestion($submissionId, array $question, array $answerArray, bool $update) { + + // get stored answers for this question + $storedAnswers = []; + if ($update) { + $storedAnswers = $this->answerMapper->findBySubmissionAndQuestion($submissionId, $question['id']); + } + + // Are we using answer ids as values + if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) { + + $newAnswerTexts = array(); + + // We are using answer ids as values + // collect names of options + foreach ($answerArray as $answer) { + $answerText = ""; - // Are we using answer ids as values - if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) { // Search corresponding option, skip processing if not found $optionIndex = array_search($answer, array_column($question['options'], 'id')); if ($optionIndex !== false) { @@ -978,19 +999,57 @@ private function storeAnswersForQuestion($submissionId, array $question, array $ } elseif (!empty($question['extraSettings']['allowOtherAnswer']) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) { $answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, "", $answer); } - } else { - $answerText = $answer; // Not a multiple-question, answerText is given answer + + $newAnswerTexts[] = $answerText; + + // has this answer already been stored? + $foundAnswer = false; + foreach($storedAnswers as $storedAnswer) { + if ($storedAnswer->getText() == $answerText) { + // nothing to be changed + $foundAnswer = true; + break; + } + } + if (!$foundAnswer) { + if ($answerText === "") { + continue; + } + + // need to add answer + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($question['id']); + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + } } - if ($answerText === "") { - continue; + // drop all answers that are not in new set of answers + foreach($storedAnswers as $storedAnswer) { + if (empty($newAnswerTexts) || !in_array($storedAnswer->getText(), $newAnswerTexts)) { + $this->answerMapper->delete($storedAnswer); + } } + } else { + // just one answer + $answerText = $answerArray[0]; // Not a multiple-question, answerText is given answer + + if (!empty($storedAnswers)) { + $answerEntity = $storedAnswers[0]; + $answerEntity->setText($answerText); + $this->answerMapper->update($answerEntity); + } else { + if ($answerText === "") { + return; + } - $answerEntity = new Answer(); - $answerEntity->setSubmissionId($submissionId); - $answerEntity->setQuestionId($question['id']); - $answerEntity->setText($answerText); - $this->answerMapper->insert($answerEntity); + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($question['id']); + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + } } } @@ -1000,22 +1059,16 @@ private function storeAnswersForQuestion($submissionId, array $question, array $ * @NoAdminRequired * @PublicPage * - * Process a new submission + * check a submission and return some required data objects * * @param int $formId the form id * @param array $answers [question_id => arrayOfString] * @param string $shareHash public share-hash -> Necessary to submit on public link-shares. - * @return DataResponse + * @return array * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function insertSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse { - $this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [ - 'formId' => $formId, - 'answers' => $answers, - 'shareHash' => $shareHash, - ]); - + private function checkAndPrepareSubmission(int $formId, array $answers, string $shareHash = ''): array { try { $form = $this->formMapper->findById($formId); $questions = $this->formsService->getQuestions($formId); @@ -1065,6 +1118,102 @@ public function insertSubmission(int $formId, array $answers, string $shareHash throw new OCSBadRequestException('At least one submitted answer is not valid'); } + return array($form, $questions); + } + + /** + * @CORS + * @PublicCORSFix + * @NoAdminRequired + * @PublicPage + * + * Update an existing submission + * + * @param int $formId the form id + * @param array $answers [question_id => arrayOfString] + * @param string $shareHash public share-hash -> Necessary to submit on public link-shares. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function updateSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse { + $this->logger->debug('Updating submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [ + 'formId' => $formId, + 'answers' => $answers, + 'shareHash' => $shareHash, + ]); + + list($form, $questions) = $this->checkAndPrepareSubmission($formId, $answers, $shareHash); + + // if edit is allowed get existing submission of this user + if ($form->getAllowEdit() && $this->currentUser) { + try { + $submission = $this->submissionMapper->findByFormAndUser($form->getId(), $this->currentUser->getUID()); + } catch (DoesNotExistException $e) { + throw new OCSBadRequestException('Cannot update a non existing submission'); + } + } else { + throw new OCSBadRequestException('Can only update if AllowEdit is set'); + } + + $submission->setTimestamp(time()); + $this->submissionMapper->update($submission); + + // Process Answers + foreach ($answers as $questionId => $answerArray) { + // Search corresponding Question, skip processing if not found + $questionIndex = array_search($questionId, array_column($questions, 'id')); + if ($questionIndex === false) { + continue; + } + + $this->storeAnswersForQuestion($submission->getId(), $questions[$questionIndex], $answerArray, true); + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + //Create Activity + $this->activityManager->publishNewSubmission($form, $submission->getUserId()); + + return new DataResponse(); + } + + /** + * @CORS + * @PublicCORSFix + * @NoAdminRequired + * @PublicPage + * + * Process a new submission + * + * @param int $formId the form id + * @param array $answers [question_id => arrayOfString] + * @param string $shareHash public share-hash -> Necessary to submit on public link-shares. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function insertSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse { + $this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [ + 'formId' => $formId, + 'answers' => $answers, + 'shareHash' => $shareHash, + ]); + + list($form, $questions) = $this->checkAndPrepareSubmission($formId, $answers, $shareHash); + + // if edit is allowed then do not allow inserting another submission if one exists already + if ($form->getAllowEdit() && $this->currentUser) { + try { + $submission = $this->submissionMapper->findByFormAndUser($form->getId(), $this->currentUser->getUID()); + + // we do not want to add another submission + throw new OCSForbiddenException('Do not insert another submission'); + } catch (DoesNotExistException $e) { + // all is good + } + } + // Create Submission $submission = new Submission(); $submission->setFormId($formId); @@ -1080,7 +1229,6 @@ public function insertSubmission(int $formId, array $answers, string $shareHash // Insert new submission $this->submissionMapper->insert($submission); - $submissionId = $submission->getId(); // Process Answers foreach ($answers as $questionId => $answerArray) { @@ -1090,7 +1238,7 @@ public function insertSubmission(int $formId, array $answers, string $shareHash continue; } - $this->storeAnswersForQuestion($submission->getId(), $questions[$questionIndex], $answerArray); + $this->storeAnswersForQuestion($submission->getId(), $questions[$questionIndex], $answerArray, false); } $this->formsService->setLastUpdatedTimestamp($formId); @@ -1125,8 +1273,13 @@ public function deleteSubmission(int $id): DataResponse { throw new OCSBadRequestException(); } + $canDeleteSubmission = false; + if ($form->getAllowEdit() && $submission->getUserId() == $this->currentUser->getUID()) { + $canDeleteSubmission = true; + } + // The current user has permissions to remove submissions - if (!$this->formsService->canDeleteResults($form)) { + if (!$canDeleteSubmission && !$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/AnswerMapper.php b/lib/Db/AnswerMapper.php index 7ab374eae..bf699e527 100644 --- a/lib/Db/AnswerMapper.php +++ b/lib/Db/AnswerMapper.php @@ -60,6 +60,26 @@ public function findBySubmission(int $submissionId): array { return $this->findEntities($qb); } + /** + * @param int $submissionId + * @param int $questionId + * @throws \OCP\AppFramework\Db\DoesNotExistException if not found + * @return Answer[] + */ + + public function findBySubmissionAndQuestion(int $submissionId, int $questionId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('submission_id', $qb->createNamedParameter($submissionId, IQueryBuilder::PARAM_INT)), + $qb->expr()->eq('question_id', $qb->createNamedParameter($questionId, IQueryBuilder::PARAM_INT)) + ); + + return $this->findEntities($qb); + } + /** * @param int $submissionId */ diff --git a/lib/Db/Form.php b/lib/Db/Form.php index 14e4d5803..bc96b33b3 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -48,6 +48,8 @@ * @method void setIsAnonymous(bool $value) * @method integer getSubmitMultiple() * @method void setSubmitMultiple(bool $value) + * @method integer getAllowEdit() + * @method void setAllowEdit(bool $value) * @method integer getShowExpiration() * @method void setShowExpiration(bool $value) * @method integer getLastUpdated() @@ -65,6 +67,7 @@ class Form extends Entity { protected $expires; protected $isAnonymous; protected $submitMultiple; + protected $allowEdit; protected $showExpiration; protected $submissionMessage; protected $lastUpdated; @@ -77,6 +80,7 @@ public function __construct() { $this->addType('expires', 'integer'); $this->addType('isAnonymous', 'bool'); $this->addType('submitMultiple', 'bool'); + $this->addType('allowEdit', 'bool'); $this->addType('showExpiration', 'bool'); $this->addType('lastUpdated', 'integer'); } @@ -104,6 +108,7 @@ public function read() { 'expires' => (int)$this->getExpires(), 'isAnonymous' => (bool)$this->getIsAnonymous(), 'submitMultiple' => (bool)$this->getSubmitMultiple(), + 'allowEdit' => (bool)$this->getAllowEdit(), 'showExpiration' => (bool)$this->getShowExpiration(), 'lastUpdated' => (int)$this->getLastUpdated(), 'submissionMessage' => $this->getSubmissionMessage(), diff --git a/lib/Db/SubmissionMapper.php b/lib/Db/SubmissionMapper.php index ed5d0c03d..3f704b2fc 100644 --- a/lib/Db/SubmissionMapper.php +++ b/lib/Db/SubmissionMapper.php @@ -67,6 +67,29 @@ public function findByForm(int $formId): array { return $this->findEntities($qb); } + /** + * @param int $formId + * @param string $userId + * + * @return Submission + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException if more than one result + * @throws \OCP\AppFramework\Db\DoesNotExistException if not found + */ + public function findByFormAndUser(int $formId, string $userId): Submission { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)), + $qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) + ) + //Newest submissions first + ->orderBy('timestamp', 'DESC'); + + return $this->findEntity($qb); + } + /** * @param int $id * @return Submission diff --git a/lib/FormsMigrator.php b/lib/FormsMigrator.php index 2d9f6e877..195293b5d 100644 --- a/lib/FormsMigrator.php +++ b/lib/FormsMigrator.php @@ -198,6 +198,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $form->setExpires($formData['expires']); $form->setIsAnonymous($formData['isAnonymous']); $form->setSubmitMultiple($formData['submitMultiple']); + $form->setAllowEdit($formData['allowEdit']); $form->setShowExpiration($formData['showExpiration']); $form->setLastUpdated($formData['lastUpdated']); diff --git a/lib/Migration/Version030300Date20230815000000.php b/lib/Migration/Version030300Date20230815000000.php new file mode 100644 index 000000000..fb17c1aa3 --- /dev/null +++ b/lib/Migration/Version030300Date20230815000000.php @@ -0,0 +1,59 @@ + + * + * @author Timotheus Pokorra + * + * @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 Version030300Date20230815000000 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_forms'); + + if (!$table->hasColumn('allow_edit')) { + $table->addColumn('allow_edit', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => 0, + ]); + + return $schema; + } + + return null; + } +} diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 0af552978..44025a00e 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -26,6 +26,7 @@ use OCA\Forms\Activity\ActivityManager; use OCA\Forms\Constants; +use OCA\Forms\Db\AnswerMapper; use OCA\Forms\Db\Form; use OCA\Forms\Db\FormMapper; use OCA\Forms\Db\OptionMapper; @@ -36,6 +37,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; @@ -59,6 +61,7 @@ public function __construct( private QuestionMapper $questionMapper, private ShareMapper $shareMapper, private SubmissionMapper $submissionMapper, + private AnswerMapper $answerMapper, private ConfigService $configService, private IGroupManager $groupManager, private LoggerInterface $logger, @@ -99,6 +102,32 @@ public function getOptions(int $questionId): array { } } + private function getAnswers(int $formId, int $submissionId, string $userId): array { + + $answerList = []; + $answerEntities = $this->answerMapper->findBySubmission($submissionId); + foreach ($answerEntities as $answerEntity) { + $answer = $answerEntity->read(); + $questionId = $answer['questionId']; + if (!array_key_exists($questionId, $answerList)) { + $answerList[$questionId] = array(); + } + $options = $this->getOptions($answer['questionId']); + if (!empty($options)) { + // match option text to option index + foreach ($options as $option) { + if ($option['text'] == $answer['text']) { + $answerList[$questionId][] = $option['id']; + } + } + } else { + // copy the text + $answerList[$questionId][] = $answer['text']; + } + } + return $answerList; + } + /** * Load questions corresponding to form * @@ -150,6 +179,25 @@ public function getShares(int $formId): array { public function getForm(Form $form): array { $result = $form->read(); $result['questions'] = $this->getQuestions($form->getId()); + + // add previous submission if there is one by this user for this form + if ($this->currentUser->getUID() && $form->getAllowEdit()) { + $submissionEntity = null; + try { + $submissionEntity = $this->submissionMapper->findByFormAndUser($form->getId(), $this->currentUser->getUID()); + $answers = $this->getAnswers($form->getId(), $submissionEntity->getId(), $this->currentUser->getUID()); + if (!empty($answers)) { + $result['answers'] = $answers; + $result['newSubmission'] = false; + $result['submissionId'] = $submissionEntity->getId(); + } + } catch (DoesNotExistException $e) { + // do nothing + } catch (MultipleObjectsReturnedException $e) { + // do nothing + } + } + $result['shares'] = $this->getShares($form->getId()); // Append permissions for current user. @@ -279,8 +327,8 @@ public function canSubmit(Form $form): bool { return true; } - // Refuse access, if SubmitMultiple is not set and user already has taken part. - if (!$form->getSubmitMultiple()) { + // Refuse access, if SubmitMultiple is not set and AllowEdit is not set and user already has taken part. + if (!$form->getSubmitMultiple() && !$form->getAllowEdit()) { $participants = $this->submissionMapper->findParticipantsByForm($form->getId()); foreach ($participants as $participant) { if ($participant === $this->currentUser->getUID()) { diff --git a/src/components/Questions/QuestionMultiple.vue b/src/components/Questions/QuestionMultiple.vue index 067d6c944..bc182c2d1 100644 --- a/src/components/Questions/QuestionMultiple.vue +++ b/src/components/Questions/QuestionMultiple.vue @@ -242,6 +242,14 @@ export default { mounted() { // Ensure the initial "other" answer is set this.resetOtherAnswerText() + + // Init selected options from values prop + if (this.values) { + this.questionValues = [] + this.values.forEach(i => { + this.questionValues.push(i.toString()) + }) + } }, methods: { diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index e6ca684f9..9432752ec 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -36,6 +36,13 @@ @update:checked="onSubmitMultipleChange"> {{ t('forms', 'Allow multiple responses per person') }} + + {{ t('forms', 'Allow editing and deleting responses per person') }} + @@ -149,11 +156,23 @@ export default { * Submit Multiple is disabled, if it cannot be controlled. */ disableSubmitMultiple() { - return this.hasPublicLink || this.form.access.legacyLink || this.form.isAnonymous + return this.hasPublicLink || this.form.access.legacyLink || this.form.isAnonymous || this.form.allowEdit }, disableSubmitMultipleExplanation() { if (this.disableSubmitMultiple) { - return t('forms', 'This can not be controlled, if the form has a public link or stores responses anonymously.') + return t('forms', 'This can not be controlled, if the form has a public link or stores responses anonymously, or the response can be edited.') + } + return '' + }, + /** + * Allow Edit is disabled, if it cannot be controlled. + */ + disableAllowEdit() { + return this.hasPublicLink || this.form.access.legacyLink || this.form.isAnonymous || this.form.submitMultiple + }, + disableAllowEditExplanation() { + if (this.disableAllowEdit) { + return t('forms', 'This can not be controlled, if the form has a public link or stores responses anonymously, or multiple responses are allowed.') } return '' }, @@ -163,11 +182,22 @@ export default { .length !== 0 }, - // If disabled, submitMultiple will be casted to true + // If disabled, submitMultiple will be casted to false if allowEdit is true, else casted to true submitMultiple() { + if (this.disableSubmitMultiple && this.allowEdit) { + return false + } return this.disableSubmitMultiple || this.form.submitMultiple }, + // If disabled, allowEdit will be casted to false + allowEdit() { + if (this.disableAllowEdit) { + return false + } + return this.form.allowEdit + }, + formExpires() { return this.form.expires !== 0 }, @@ -197,6 +227,9 @@ export default { onSubmitMultipleChange(checked) { this.$emit('update:formProp', 'submitMultiple', checked) }, + onAllowEditChange(checked) { + this.$emit('update:formProp', 'allowEdit', checked) + }, onFormExpiresChange(checked) { if (checked) { this.$emit('update:formProp', 'expires', moment().add(1, 'hour').unix()) // Expires in one hour. diff --git a/src/mixins/ViewsMixin.js b/src/mixins/ViewsMixin.js index 3c83363e1..eb9f740f5 100644 --- a/src/mixins/ViewsMixin.js +++ b/src/mixins/ViewsMixin.js @@ -128,8 +128,14 @@ export default { try { const response = await request(generateOcsUrl('apps/forms/api/v2.2/form/{id}', { id })) - this.$emit('update:form', OcsResponse2Data(response)) + await this.$emit('update:form', OcsResponse2Data(response)) this.isLoadingForm = false + // load answers from the server into this form + if (this.form.answers) { + this.answers = this.form.answers + this.newSubmission = this.form.newSubmission + this.submissionId = this.form.submissionId + } } catch (error) { if (axios.isCancel(error)) { logger.debug(`The request for form ${id} has been canceled`, { error }) diff --git a/src/views/Submit.vue b/src/views/Submit.vue index 3619a7268..3f302fd79 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -105,12 +105,22 @@ @keydown.ctrl.enter="onKeydownCtrlEnter" @update:values="(values) => onUpdate(question, values)" /> - +
+ + +
@@ -477,12 +487,19 @@ export default { this.loading = true try { - await axios.post(generateOcsUrl('apps/forms/api/v2.2/submission/insert'), { - formId: this.form.id, - answers: this.answers, - shareHash: this.shareHash, - }) - this.submitForm = true + if (this.newSubmission === false) { + await axios.post(generateOcsUrl('apps/forms/api/v2.2/submission/update'), { + formId: this.form.id, + answers: this.answers, + shareHash: this.shareHash, + }) + } else { + await axios.post(generateOcsUrl('apps/forms/api/v2.2/submission/insert'), { + formId: this.form.id, + answers: this.answers, + shareHash: this.shareHash, + }) + } this.success = true this.deleteFormFieldFromLocalStorage() emit('forms:last-updated:set', this.form.id) @@ -494,11 +511,38 @@ export default { } }, + /** + * Delete the submission + */ + async onDeleteSubmission() { + if (!confirm(t('forms', 'Are you sure you want to delete your response?'))) { + return + } + + this.loading = true + + try { + if (this.newSubmission === false) { + await axios.delete(generateOcsUrl('apps/forms/api/v2.1/submission/' + this.submissionId)) + } else { + throw new Error('cannot delete new submission') + } + this.success = true + emit('forms:last-updated:set', this.form.id) + } catch (error) { + logger.error('Error while deleting the form submission', { error }) + showError(t('forms', 'There was an error deleting the form submission')) + } finally { + this.loading = false + } + }, + /** * Reset View-Data */ resetData() { this.answers = {} + this.newSubmission = true this.loading = false this.success = false this.submitForm = false @@ -586,6 +630,13 @@ export default { padding-inline-start: 44px; } + .buttons { + align-self: flex-end; + margin: 5px; + margin-block-end: 160px; + padding-block: 10px; + padding-inline: 20px; + } input[type=submit] { align-self: flex-end; margin: 5px; @@ -593,6 +644,14 @@ export default { padding-block: 10px; padding-inline: 20px; } + input[type=button].secondary { + align-self: flex-end; + margin: 5px; + margin-block-end: 160px; + padding-block: 10px; + padding-inline: 20px; + } + } } diff --git a/tests/Integration/Api/ApiV2Test.php b/tests/Integration/Api/ApiV2Test.php index 6ae7adc3d..031bf35dd 100644 --- a/tests/Integration/Api/ApiV2Test.php +++ b/tests/Integration/Api/ApiV2Test.php @@ -65,6 +65,7 @@ private function setTestForms() { 'expires' => 0, 'is_anonymous' => false, 'submit_multiple' => false, + 'allow_edit' => false, 'show_expiration' => false, 'last_updated' => 123456789, 'submission_message' => 'Back to website', @@ -168,6 +169,7 @@ private function setTestForms() { 'expires' => 0, 'is_anonymous' => false, 'submit_multiple' => false, + 'allow_edit' => false, 'show_expiration' => false, 'last_updated' => 123456789, 'submission_message' => '', @@ -231,6 +233,7 @@ public function setUp(): void { 'expires' => $qb->createNamedParameter($form['expires'], IQueryBuilder::PARAM_INT), 'is_anonymous' => $qb->createNamedParameter($form['is_anonymous'], IQueryBuilder::PARAM_BOOL), 'submit_multiple' => $qb->createNamedParameter($form['submit_multiple'], IQueryBuilder::PARAM_BOOL), + 'allow_edit' => $qb->createNamedParameter($form['allow_edit'], IQueryBuilder::PARAM_BOOL), 'show_expiration' => $qb->createNamedParameter($form['show_expiration'], IQueryBuilder::PARAM_BOOL), 'last_updated' => $qb->createNamedParameter($form['last_updated'], IQueryBuilder::PARAM_INT), 'submission_message' => $qb->createNamedParameter($form['submission_message'], IQueryBuilder::PARAM_STR), @@ -484,6 +487,7 @@ public function dataGetNewForm() { 'expires' => 0, 'isAnonymous' => false, 'submitMultiple' => false, + 'allowEdit' => false, 'showExpiration' => false, // 'lastUpdated' => time() can not be checked exactly 'canSubmit' => true, @@ -540,6 +544,7 @@ public function dataGetFullForm() { 'expires' => 0, 'isAnonymous' => false, 'submitMultiple' => false, + 'allowEdit' => false, 'showExpiration' => false, 'lastUpdated' => 123456789, 'canSubmit' => true, diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index 47de63b8c..d4c4749ae 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -45,6 +45,7 @@ function time($expected = null) { namespace OCA\Forms\Tests\Unit\Controller; +use OCA\Forms\Activity\ActivityManager; use OCA\Forms\Constants; use OCA\Forms\Controller\ApiController; use OCA\Forms\Db\AnswerMapper; @@ -79,6 +80,8 @@ function time($expected = null) { class ApiControllerTest extends TestCase { private ApiController $apiController; + /** @var ActivityManager|MockObject */ + private $activityManager; /** @var AnswerMapper|MockObject */ private $answerMapper; /** @var FormMapper|MockObject */ @@ -107,6 +110,7 @@ class ApiControllerTest extends TestCase { private $l10n; public function setUp(): void { + $this->activityManager = $this->createMock(ActivityManager::class); $this->answerMapper = $this->createMock(AnswerMapper::class); $this->formMapper = $this->createMock(FormMapper::class); $this->optionMapper = $this->createMock(OptionMapper::class); @@ -129,6 +133,7 @@ public function setUp(): void { 'forms', $this->request, $this->createUserSession(), + $this->activityManager, $this->answerMapper, $this->formMapper, $this->optionMapper, @@ -357,6 +362,7 @@ public function dataTestCreateNewForm() { 'expires' => 0, 'isAnonymous' => false, 'submitMultiple' => false, + 'allowEdit' => false, 'showExpiration' => false, 'lastUpdated' => 123456789, 'submissionMessage' => '', @@ -375,6 +381,7 @@ public function testCreateNewForm($expectedForm) { ->setConstructorArgs(['forms', $this->request, $this->createUserSession(), + $this->activityManager, $this->answerMapper, $this->formMapper, $this->optionMapper, @@ -470,6 +477,7 @@ public function dataCloneForm() { 'expires' => 0, 'isAnonymous' => false, 'submitMultiple' => false, + 'allowEdit' => false, 'showExpiration' => false ], 'new' => [ @@ -485,6 +493,7 @@ public function dataCloneForm() { 'expires' => 0, 'isAnonymous' => false, 'submitMultiple' => false, + 'allowEdit' => false, 'showExpiration' => false ] ] @@ -532,6 +541,7 @@ public function testCloneForm($old, $new) { ->setConstructorArgs(['forms', $this->request, $this->createUserSession(), + $this->activityManager, $this->answerMapper, $this->formMapper, $this->optionMapper, @@ -641,7 +651,7 @@ public function testInsertSubmission_answers() { return true; })); - $this->answerMapper->expects($this->exactly(4)) + $this->answerMapper->expects($this->exactly(5)) ->method('insert') ->with($this->callback(function ($answer) { if ($answer->getSubmissionId() !== 12) { diff --git a/tests/Unit/FormsMigratorTest.php b/tests/Unit/FormsMigratorTest.php index 2df4991b1..0c4b7e63d 100644 --- a/tests/Unit/FormsMigratorTest.php +++ b/tests/Unit/FormsMigratorTest.php @@ -114,7 +114,7 @@ public function setUp(): void { public function dataExport() { return [ 'exactlyOneOfEach' => [ - 'expectedJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"isAnonymous":false,"submitMultiple":false,"showExpiration":false,"lastUpdated":123456789,"submissionMessage":"Back to website","questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' + 'expectedJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"isAnonymous":false,"submitMultiple":false,"allowEdit":false,"showExpiration":false,"lastUpdated":123456789,"submissionMessage":"Back to website","questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' ] ]; } @@ -149,6 +149,7 @@ public function testExport(string $expectedJson) { $form->setExpires(0); $form->setIsAnonymous(false); $form->setSubmitMultiple(false); + $form->setAllowEdit(false); $form->setShowExpiration(false); $form->setLastUpdated(123456789); $form->setSubmissionMessage('Back to website'); @@ -222,7 +223,7 @@ public function testExport(string $expectedJson) { public function dataImport() { return [ 'exactlyOneOfEach' => [ - '$inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"isAnonymous":false,"submitMultiple":false,"showExpiration":false,"lastUpdated":123456789,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' + '$inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"isAnonymous":false,"submitMultiple":false,"allowEdit":false,"showExpiration":false,"lastUpdated":123456789,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' ] ]; } diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index 1f62e4bd1..468a13997 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -28,6 +28,7 @@ use OCA\Forms\Activity\ActivityManager; use OCA\Forms\Constants; +use OCA\Forms\Db\AnswerMapper; use OCA\Forms\Db\Form; use OCA\Forms\Db\FormMapper; use OCA\Forms\Db\Option; @@ -60,6 +61,9 @@ class FormsServiceTest extends TestCase { /** @var ActivityManager|MockObject */ private $activityManager; + /** @var AnswerMapper|MockObject */ + private $answerMapper; + /** @var FormMapper|MockObject */ private $formMapper; @@ -101,6 +105,7 @@ public function setUp(): void { $this->questionMapper = $this->createMock(QuestionMapper::class); $this->shareMapper = $this->createMock(ShareMapper::class); $this->submissionMapper = $this->createMock(SubmissionMapper::class); + $this->answerMapper = $this->createMock(AnswerMapper::class); $this->configService = $this->createMock(ConfigService::class); $this->groupManager = $this->createMock(IGroupManager::class); @@ -126,6 +131,7 @@ public function setUp(): void { $this->questionMapper, $this->shareMapper, $this->submissionMapper, + $this->answerMapper, $this->configService, $this->groupManager, $this->logger, @@ -161,6 +167,7 @@ public function dataGetForm() { 'expires' => 0, 'isAnonymous' => false, 'submitMultiple' => true, + 'allowEdit' => false, 'showExpiration' => false, 'lastUpdated' => 123456789, 'canSubmit' => true, @@ -239,6 +246,7 @@ public function testGetForm(array $expected) { $form->setExpires(0); $form->setIsAnonymous(false); $form->setSubmitMultiple(true); + $form->setAllowEdit(false); $form->setShowExpiration(false); $form->setLastUpdated(123456789); @@ -413,6 +421,7 @@ public function dataGetPublicForm() { 'lastUpdated' => 123456789, 'isAnonymous' => false, 'submitMultiple' => true, + 'allowEdit' => false, 'showExpiration' => false, 'canSubmit' => true, 'questions' => [], @@ -445,6 +454,7 @@ public function testGetPublicForm(array $expected) { $form->setLastUpdated(123456789); $form->setIsAnonymous(false); $form->setSubmitMultiple(true); + $form->setAllowEdit(false); $form->setShowExpiration(false); // User & Group Formatting @@ -585,6 +595,7 @@ public function testGetPermissions_NotLoggedIn() { $this->questionMapper, $this->shareMapper, $this->submissionMapper, + $this->answerMapper, $this->configService, $this->groupManager, $this->logger, @@ -738,24 +749,28 @@ public function dataCanSubmit() { 'allowFormOwner' => [ 'ownerId' => 'currentUser', 'submitMultiple' => false, + 'allowEdit' => false, 'participantsArray' => ['currentUser'], 'expected' => true ], 'submitMultipleGood' => [ 'ownerId' => 'someUser', 'submitMultiple' => false, + 'allowEdit' => false, 'participantsArray' => ['notCurrentUser'], 'expected' => true ], 'submitMultipleNotGood' => [ 'ownerId' => 'someUser', 'submitMultiple' => false, + 'allowEdit' => false, 'participantsArray' => ['notCurrentUser', 'currentUser'], 'expected' => false ], 'submitMultiple' => [ 'ownerId' => 'someUser', 'submitMultiple' => true, + 'allowEdit' => false, 'participantsArray' => ['currentUser'], 'expected' => true ] @@ -766,10 +781,11 @@ public function dataCanSubmit() { * * @param string $ownerId * @param bool $submitMultiple + * @param bool $allowEdit * @param array $participantsArray * @param bool $expected */ - public function testCanSubmit(string $ownerId, bool $submitMultiple, array $participantsArray, bool $expected) { + public function testCanSubmit(string $ownerId, bool $submitMultiple, bool $allowEdit, array $participantsArray, bool $expected) { $form = new Form(); $form->setId(42); $form->setAccess([ @@ -778,6 +794,7 @@ public function testCanSubmit(string $ownerId, bool $submitMultiple, array $part ]); $form->setOwnerId($ownerId); $form->setSubmitMultiple($submitMultiple); + $form->setAllowEdit($allowEdit); $this->submissionMapper->expects($this->any()) ->method('findParticipantsByForm') @@ -822,6 +839,7 @@ public function testPublicCanSubmit() { $this->questionMapper, $this->shareMapper, $this->submissionMapper, + $this->answerMapper, $this->configService, $this->groupManager, $this->logger, @@ -982,6 +1000,7 @@ public function testHasUserAccess_NotLoggedIn() { $this->questionMapper, $this->shareMapper, $this->submissionMapper, + $this->answerMapper, $this->configService, $this->groupManager, $this->logger, @@ -1398,6 +1417,7 @@ public function testNotifyNewSubmission($shares, $shareNotifications) { $this->questionMapper, $this->shareMapper, $this->submissionMapper, + $this->answerMapper, $this->configService, $this->groupManager, $this->logger,