Merge pull request #308 from nextcloud/enh/submit

This commit is contained in:
John Molakvoæ 2020-04-28 21:45:10 +02:00 committed by GitHub
commit 4f42db1bac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 703 additions and 577 deletions

View file

@ -1,4 +1,7 @@
module.exports = {
globals: {
appName: true,
},
extends: [
'@nextcloud',
]

View file

@ -32,7 +32,6 @@ return [
['name' => 'page#getResult', 'url' => '/{hash}/results', 'verb' => 'GET'],
['name' => 'page#goto_form', 'url' => '/{hash}', 'verb' => 'GET'],
['name' => 'page#insert_submission', 'url' => '/insert/submission', 'verb' => 'POST'],
// Forms
['name' => 'api#getForms', 'url' => '/api/v1/forms', 'verb' => 'GET'],
@ -53,6 +52,7 @@ return [
['name' => 'api#deleteOption', 'url' => '/api/v1/option/{id}', 'verb' => 'DELETE'],
['name' => 'api#getSubmissions', 'url' => '/api/v1/submissions/{hash}', 'verb' => 'GET'],
['name' => 'api#insertSubmission', 'url' => '/api/v1/submissions/insert', 'verb' => 'POST'],
['name' => 'system#get_site_users_and_groups', 'url' => '/get/siteusers', 'verb' => 'POST'],
]

View file

@ -1,60 +0,0 @@
var form = []
var questions = []
function sendDataToServer(survey) {
form.answers = survey.data;
form.userId = OC.getCurrentUser().uid;
if(form.userId == ''){
form.userId = 'anon_' + Date.now() + '_' + Math.floor(Math.random() * 10000)
}
form.questions = questions;
$.post(OC.generateUrl('apps/forms/insert/submission'), form)
.then((response) => {
}, (error) => {
/* eslint-disable-next-line no-console */
console.log(error.response)
});
}
function cssUpdate(survey, options){
console.log(options.cssClasses)
var classes = options.cssClasses
classes.root = 'sq-root'
classes.title = 'sq-title'
classes.item = 'sq-item'
classes.label = 'sq-label'
classes.description = 'sv-q-description'
if (options.question.isRequired) {
classes.title = 'sq-title sq-title-required'
classes.root = 'sq-root sq-root-required'
}
}
$(document).ready(function () {
var formJSON = $('#surveyContainer').attr('form')
var questionJSON = $('#surveyContainer').attr('questions')
form = JSON.parse(formJSON)
questions = JSON.parse(questionJSON)
var surveyJSON = {
title: form.title,
description: form.description,
questions: []
};
questions.forEach(q => {
var qChoices = []
q.options.forEach(o => {
qChoices.push(o.text);
});
surveyJSON.questions.push({type: q.type, name: q.text, choices: qChoices, isRequired: 'true'});
});
$('#surveyContainer').Survey({
model: new Survey.Model(surveyJSON),
onUpdateQuestionCssClasses: cssUpdate,
onComplete: sendDataToServer,
});
});

File diff suppressed because one or more lines are too long

View file

@ -29,92 +29,78 @@
namespace OCA\Forms\Controller;
use OCA\Forms\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\IMapperException;
use OCP\ILogger;
use OCP\IRequest;
use OCP\IUser;
use OCP\Security\ISecureRandom;
use OCA\Forms\Db\Form;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\Question;
use OCA\Forms\Db\QuestionMapper;
use OCA\Forms\Db\Option;
use OCA\Forms\Db\OptionMapper;
use OCA\Forms\Db\Submission;
use OCA\Forms\Db\SubmissionMapper;
use OCA\Forms\Db\Answer;
use OCA\Forms\Db\AnswerMapper;
use OCA\Forms\Db\Form;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\Option;
use OCA\Forms\Db\OptionMapper;
use OCA\Forms\Db\Question;
use OCA\Forms\Db\QuestionMapper;
use OCA\Forms\Db\Submission;
use OCA\Forms\Db\SubmissionMapper;
use OCA\Forms\Service\FormsService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\IMapperException;
use OCP\AppFramework\Http;
use OCP\ILogger;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Security\ISecureRandom;
class ApiController extends Controller {
private $formMapper;
protected $appName;
/** @var SubmissionMapper */
private $submissionMapper;
private $answerMapper;
/** @var FormMapper */
private $formMapper;
/** @var QuestionMapper */
private $questionMapper;
/** @var OptionMapper */
private $optionMapper;
/** @var AnswerMapper */
private $answerMapper;
/** @var ILogger */
private $logger;
/** @var string */
private $userId;
/** @var IUserSession */
private $userSession;
/** @var FormsService */
private $formsService;
public function __construct(
IRequest $request,
$userId,
FormMapper $formMapper,
SubmissionMapper $submissionMapper,
AnswerMapper $answerMapper,
QuestionMapper $questionMapper,
OptionMapper $optionMapper,
ILogger $logger
) {
parent::__construct(Application::APP_ID, $request);
public function __construct(string $appName,
IRequest $request,
$userId, // TODO remove & replace with userSession below.
IUserSession $userSession,
FormMapper $formMapper,
SubmissionMapper $submissionMapper,
AnswerMapper $answerMapper,
QuestionMapper $questionMapper,
OptionMapper $optionMapper,
ILogger $logger,
FormsService $formsService) {
parent::__construct($appName, $request);
$this->appName = $appName;
$this->userId = $userId;
$this->userSession = $userSession;
$this->formMapper = $formMapper;
$this->questionMapper = $questionMapper;
$this->optionMapper = $optionMapper;
$this->submissionMapper = $submissionMapper;
$this->answerMapper = $answerMapper;
$this->questionMapper = $questionMapper;
$this->optionMapper = $optionMapper;
$this->logger = $logger;
}
private function getOptions(int $questionId): array {
$optionList = [];
try{
$optionEntities = $this->optionMapper->findByQuestion($questionId);
foreach ($optionEntities as $optionEntity) {
$optionList[] = $optionEntity->read();
}
} catch (DoesNotExistException $e) {
//handle silently
} finally {
return $optionList;
}
}
private function getQuestions(int $formId): array {
$questionList = [];
try{
$questionEntities = $this->questionMapper->findByForm($formId);
foreach ($questionEntities as $questionEntity) {
$question = $questionEntity->read();
$question['options'] = $this->getOptions($question['id']);
$questionList[] = $question;
}
} catch (DoesNotExistException $e) {
//handle silently
}finally{
return $questionList;
}
$this->formsService = $formsService;
}
/**
@ -140,20 +126,19 @@ class ApiController extends Controller {
/**
* @NoAdminRequired
*
*
* Read all information to edit a Form (form, questions, options, except submissions/answers).
*/
public function getForm(int $id): Http\JSONResponse {
try {
$form = $this->formMapper->findById($id);
$results = $this->formsService->getForm($id);
} catch (IMapperException $e) {
$this->logger->debug('Could not find form');
return new Http\JSONResponse([], Http::STATUS_BAD_REQUEST);
}
$result = $form->read();
$result['questions'] = $this->getQuestions($id);
return new Http\JSONResponse($result);
return new Http\JSONResponse($results);
}
/**
@ -603,4 +588,84 @@ class ApiController extends Controller {
return new Http\JSONResponse($result);
}
/**
* @NoAdminRequired
* @PublicPage
*
* Process a new submission
* @param int $formId
* @param array $answers [question_id => arrayOfString]
*/
public function insertSubmission(int $formId, array $answers) {
$this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}', [
'formId' => $formId,
'answers' => $answers,
]);
try {
$form = $this->formMapper->findById($formId);
$questions = $this->formsService->getQuestions($formId);
} catch (IMapperException $e) {
$this->logger->debug('Could not find form');
return new Http\JSONResponse(['message' => 'Could not find form'], Http::STATUS_BAD_REQUEST);
}
$user = $this->userSession->getUser();
// TODO check again hasUserAccess?!
// Create Submission
$submission = new Submission();
$submission->setFormId($formId);
$submission->setTimestamp(time());
// If not logged in or anonymous use anonID
if (!$user || $form->getIsAnonymous()){
$anonID = "anon-user-". hash('md5', (time() + rand()));
$submission->setUserId($anonID);
} else {
$submission->setUserId($user->getUID());
}
// Insert new submission
$this->submissionMapper->insert($submission);
$submissionId = $submission->getId();
// 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;
} else {
$question = $questions[$questionIndex];
}
foreach($answerArray as $answer) {
if($question['type'] === 'multiple' || $question['type'] === 'multiple_unique') {
// Search corresponding option, skip processing if not found
$optionIndex = array_search($answer, array_column($question['options'], 'id'));
if($optionIndex === false) {
continue;
} else {
$option = $question['options'][$optionIndex];
}
// Load option-text
$answerText = $option['text'];
} else {
$answerText = $answer; // Not a multiple-question, answerText is given answer
}
$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
$answerEntity->setQuestionId($question['id']);
$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);
}
}
return new Http\JSONResponse([]);
}
}

View file

@ -29,74 +29,65 @@
namespace OCA\Forms\Controller;
use OCA\Forms\AppInfo\Application;
use OCA\Forms\Db\Form;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\Submission;
use OCA\Forms\Db\SubmissionMapper;
use OCA\Forms\Db\Answer;
use OCA\Forms\Db\AnswerMapper;
use OCA\Forms\Db\OptionMapper;
use OCA\Forms\Db\QuestionMapper;
use OCA\Forms\Service\FormsService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IInitialStateService;
use OCP\IRequest;
use OCP\ILogger;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\User; //To do: replace according to API
use OCP\IUserSession;
use OCP\Util;
class PageController extends Controller {
private $userId;
protected $appName;
/** @var FormMapper */
private $formMapper;
private $submissionMapper;
private $answerMapper;
private $questionMapper;
private $optionMapper;
/** @var IURLGenerator */
private $urlGenerator;
private $userMgr;
/** @var IGroupManager */
private $groupManager;
/** @var IUserSession */
private $userSession;
/** @var IInitialStateService */
private $initialStateService;
/** @var FormsService */
private $formService;
/** @var ILogger */
private $logger;
public function __construct(string $appName,
IRequest $request,
IGroupManager $groupManager,
IURLGenerator $urlGenerator,
FormMapper $formMapper,
QuestionMapper $questionMapper,
OptionMapper $optionMapper,
IUserSession $userSession,
IInitialStateService $initialStateService,
FormsService $formsService) {
parent::__construct($appName, $request);
public function __construct(
IRequest $request,
IUserManager $userMgr,
IGroupManager $groupManager,
IURLGenerator $urlGenerator,
$userId,
FormMapper $formMapper,
QuestionMapper $questionMapper,
OptionMapper $optionMapper,
SubmissionMapper $SubmissionMapper,
AnswerMapper $AnswerMapper,
ILogger $logger
) {
parent::__construct(Application::APP_ID, $request);
$this->userMgr = $userMgr;
$this->groupManager = $groupManager;
$this->urlGenerator = $urlGenerator;
$this->userId = $userId;
$this->appName = $appName;
$this->formMapper = $formMapper;
$this->questionMapper = $questionMapper;
$this->optionMapper = $optionMapper;
$this->submissionMapper = $SubmissionMapper;
$this->answerMapper = $AnswerMapper;
$this->logger = $logger;
$this->userSession = $userSession;
$this->initialStateService = $initialStateService;
$this->formsService = $formsService;
}
/**
@ -126,6 +117,8 @@ class PageController extends Controller {
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* TODO: Implement cloning
*
* @return TemplateResponse
*/
@ -167,145 +160,57 @@ class PageController extends Controller {
* @return TemplateResponse
*/
public function gotoForm($hash): ?TemplateResponse {
// Inject style on all templates
Util::addStyle($this->appName, 'forms');
// TODO: check if already submitted
try {
$form = $this->formMapper->findByHash($hash);
} catch (DoesNotExistException $e) {
return new TemplateResponse('forms', 'no.acc.tmpl', []);
return new TemplateResponse('forms', 'notfound');
}
// If form expired, return Expired-Template
if ( ($form->getExpires() !== 0) && (time() > $form->getExpires()) ) {
return new TemplateResponse('forms', 'expired.tmpl');
// Does the user have permissions to display
if (!$this->hasUserAccess($form)) {
return new TemplateResponse('forms', 'notfound');
}
if ($this->hasUserAccess($form)) {
$renderAs = $this->userId !== null ? 'user' : 'public';
$res = new TemplateResponse('forms', 'submit.tmpl', [
'form' => $form,
'questions' => $this->getQuestions($form->getId()),
], $renderAs);
$csp = new ContentSecurityPolicy();
$csp->allowEvalScript(true);
$res->setContentSecurityPolicy($csp);
return $res;
// Has form expired
if ($form->getExpires() !== 0 && time() > $form->getExpires()) {
return new TemplateResponse('forms', 'expired');
}
User::checkLoggedIn();
return new TemplateResponse('forms', 'no.acc.tmpl', []);
}
$renderAs = $this->userSession->isLoggedIn() ? 'user' : 'public';
/**
* @NoAdminRequired
*/
public function getQuestions(int $formId): array {
$questionList = [];
try{
$questionEntities = $this->questionMapper->findByForm($formId);
foreach ($questionEntities as $questionEntity) {
$question = $questionEntity->read();
$question['options'] = $this->getOptions($question['id']);
$questionList[] = $question;
}
} catch (DoesNotExistException $e) {
//handle silently
}
return $questionList;
}
/**
* @NoAdminRequired
*/
public function getOptions(int $questionId): array {
$optionList = [];
try{
$optionEntities = $this->optionMapper->findByQuestion($questionId);
foreach ($optionEntities as $optionEntity) {
$optionList[] = $optionEntity->read();
}
} catch (DoesNotExistException $e) {
//handle silently
}
return $optionList;
}
/**
* @NoAdminRequired
* @PublicPage
* @param int $formId
* @param string $userId
* @param array $answers
* @param array $questions
* @return RedirectResponse
*/
public function insertSubmission($id, $userId, $answers, $questions) {
$form = $this->formMapper->findById($id);
$anonID = "anon-user-". hash('md5', (time() + rand()));
//Insert Submission
$submission = new Submission();
$submission->setFormId($id);
if($form->getIsAnonymous()){
$submission->setUserId($anonID);
}else{
$submission->setUserId($userId);
}
$submission->setTimestamp(time());
$this->submissionMapper->insert($submission);
$submissionId = $submission->getId();
//Insert Answers
foreach($questions as $question) {
// If question is answered, the questionText exists as key in $answers. Does not exist, when a (non-mandatory) question was not answered.
if (array_key_exists($question['text'], $answers)) {
if($question['type'] === "checkbox"){
foreach(($answers[$question['text']]) as $ansText) {
$answer = new Answer();
$answer->setSubmissionId($submissionId);
$answer->setQuestionId($question['id']);
$answer->setText($ansText);
$this->answerMapper->insert($answer);
}
} else {
$answer = new Answer();
$answer->setSubmissionId($submissionId);
$answer->setQuestionId($question['id']);
$answer->setText($answers[$question['text']]);
$this->answerMapper->insert($answer);
}
}
}
$hash = $form->getHash();
$url = $this->urlGenerator->linkToRoute('forms.page.goto_form', ['hash' => $hash]);
return new RedirectResponse($url);
Util::addScript($this->appName, 'submit');
$this->initialStateService->provideInitialState($this->appName, 'form', $this->formsService->getForm($form->getId()));
return new TemplateResponse($this->appName, 'main', [], $renderAs);
}
/**
* @NoAdminRequired
* Check if user has access to this form
*
* @param Form $form
* @return boolean
*/
private function hasUserAccess(Form $form): bool {
$access = $form->getAccess();
$ownerId = $form->getOwnerId();
$user = $this->userSession->getUser();
if ($access['type'] === 'public') {
return true;
}
// Refuse access, if not public and no user logged in.
if ($this->userId === null) {
if (!$user) {
return false;
}
// Always grant access to owner.
if ($ownerId === $this->userId) {
if ($ownerId === $user->getUID()) {
return true;
}
@ -313,7 +218,7 @@ class PageController extends Controller {
if ($form->getSubmitOnce()) {
$participants = $this->submissionMapper->findParticipantsByForm($form->getId());
foreach($participants as $participant) {
if ($participant === $this->userId) {
if ($participant === $user->getUID()) {
return false;
}
}
@ -326,13 +231,13 @@ class PageController extends Controller {
// Selected Access remains.
// Grant Access, if user is in users-Array.
if (in_array($this->userId, $access['users'])) {
if (in_array($user->getUID(), $access['users'])) {
return true;
}
// Check if access granted by group.
foreach ($access['groups'] as $group) {
if( $this->groupManager->isInGroup($this->userId, $group) ) {
if( $this->groupManager->isInGroup($user->getUID(), $group) ) {
return true;
}
}

View file

@ -0,0 +1,101 @@
<?php
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\Forms\Service;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\OptionMapper;
use OCA\Forms\Db\QuestionMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\IMapperException;
/**
* Trait for getting forms information in a service
*/
class FormsService {
/** @var FormMapper */
private $formMapper;
/** @var QuestionMapper */
private $questionMapper;
/** @var OptionMapper */
private $optionMapper;
public function __construct(FormMapper $formMapper,
QuestionMapper $questionMapper,
OptionMapper $optionMapper) {
$this->formMapper = $formMapper;
$this->questionMapper = $questionMapper;
$this->optionMapper = $optionMapper;
}
public function getOptions(int $questionId): array {
$optionList = [];
try{
$optionEntities = $this->optionMapper->findByQuestion($questionId);
foreach ($optionEntities as $optionEntity) {
$optionList[] = $optionEntity->read();
}
} catch (DoesNotExistException $e) {
//handle silently
} finally {
return $optionList;
}
}
public function getQuestions(int $formId): array {
$questionList = [];
try{
$questionEntities = $this->questionMapper->findByForm($formId);
foreach ($questionEntities as $questionEntity) {
$question = $questionEntity->read();
$question['options'] = $this->getOptions($question['id']);
$questionList[] = $question;
}
} catch (DoesNotExistException $e) {
//handle silently
}finally{
return $questionList;
}
}
/**
* Get a form data
*
* @param integer $id
* @return array
* @throws IMapperException
*/
public function getForm(int $id): array {
$form = $this->formMapper->findById($id);
$result = $form->read();
$result['questions'] = $this->getQuestions($id);
return $result;
}
}

8
package-lock.json generated
View file

@ -1600,6 +1600,14 @@
}
}
},
"@nextcloud/initial-state": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-1.1.2.tgz",
"integrity": "sha512-AmewfDmsCgL9j062VWkgWPg+dfyu63xxqv29ErAJ1WZiEQK/gb2IyiILDMTXdVeNHGDY874mzBcAAkpFO/DxnQ==",
"requires": {
"core-js": "^3.6.4"
}
},
"@nextcloud/l10n": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-1.2.3.tgz",

View file

@ -73,6 +73,7 @@
"@nextcloud/axios": "^1.3.2",
"@nextcloud/dialogs": "^1.2.2",
"@nextcloud/event-bus": "^1.1.3",
"@nextcloud/initial-state": "^1.1.2",
"@nextcloud/l10n": "^1.2.3",
"@nextcloud/moment": "^1.1.1",
"@nextcloud/router": "^1.0.2",

View file

@ -28,7 +28,8 @@
@click="enableEdit">
<!-- Drag handle -->
<!-- TODO: implement arrow key mapping to reorder question -->
<div class="question__drag-handle icon-drag-handle"
<div v-if="!readOnly"
class="question__drag-handle icon-drag-handle"
:aria-label="t('forms', 'Drag to reorder the questions')" />
<!-- Header -->
@ -45,7 +46,7 @@
@input="onInput"
@keyup="onTitleChange">
<h3 v-else class="question__header-title" v-text="text" />
<Actions class="question__header-menu" :force-menu="true">
<Actions v-if="!readOnly" class="question__header-menu" :force-menu="true">
<ActionButton icon="icon-delete" @click="onDelete">
{{ t('forms', 'Delete question') }}
</ActionButton>
@ -96,6 +97,10 @@ export default {
type: Boolean,
required: true,
},
readOnly: {
type: Boolean,
default: false,
},
},
methods: {
@ -107,14 +112,18 @@ export default {
* Enable the edit mode
*/
enableEdit() {
this.$emit('update:edit', true)
if (!this.readOnly) {
this.$emit('update:edit', true)
}
},
/**
* Disable the edit mode
*/
disableEdit() {
this.$emit('update:edit', false)
if (!this.readOnly) {
this.$emit('update:edit', false)
}
},
/**

View file

@ -32,7 +32,7 @@
<textarea ref="textarea"
:aria-label="t('forms', 'A long answer for the question “{text}”', { text })"
:placeholder="t('forms', 'Long answer text')"
:readonly="edit"
:required="true /* TODO: implement required option */"
:value="values[0]"
class="question__text"
maxlength="1024"

View file

@ -28,7 +28,7 @@
:edit.sync="edit"
@delete="onDelete"
@update:text="onTitleChange">
<ul class="question__content" :role="isUnique ? 'radiogroup' : ''">
<ul class="question__content">
<template v-for="(answer, index) in options">
<li v-if="!edit" :key="answer.id" class="question__item">
<!-- Answer radio/checkbox + label -->
@ -42,8 +42,9 @@
'checkbox question__checkbox': !isUnique,
}"
:name="`${id}-answer`"
:readonly="true"
:type="isUnique ? 'radio' : 'checkbox'">
:required="isRequired(answer.id)"
:type="isUnique ? 'radio' : 'checkbox'"
@change="onChange($event, answer.id)">
<label v-if="!edit"
ref="label"
:for="`${id}-answer-${answer.id}`"
@ -114,6 +115,10 @@ export default {
hasNoAnswer() {
return this.options.length === 0
},
areNoneChecked() {
return this.values.length === 0
},
},
watch: {
@ -126,15 +131,50 @@ export default {
},
methods: {
onChange(event, answerId) {
const isChecked = event.target.checked === true
let values = this.values.slice()
// Radio
if (this.isUnique) {
this.$emit('update:values', [answerId])
return
}
// Checkbox
if (isChecked) {
values.push(answerId)
} else {
values = values.filter(id => id !== answerId)
}
// Emit values and remove duplicates
this.$emit('update:values', [...new Set(values)])
},
/**
* Is the provided index checked
* @param {number} index the option index
* Is the provided answer checked ?
* @param {number} id the answer id
* @returns {boolean}
*/
isChecked(index) {
// TODO implement based on answers
return false
isChecked(id) {
return this.values.indexOf(id) > -1
},
/**
* Is the provided answer required ?
* This is needed for checkboxes as html5
* doesn't allow to require at least ONE checked.
* So we require the one that are checked or all
* if none are checked yet.
* @param {number} id the answer id
* @returns {boolean}
*/
isRequired(id) {
if (this.isUnique) {
return true
}
return this.areNoneChecked || this.isChecked(id)
},
/**
@ -230,13 +270,14 @@ export default {
}
</script>
<style lang="scss">
<style lang="scss" scoped>
.question__content {
display: flex;
flex-direction: column;
}
.question__item {
position: relative;
display: inline-flex;
align-items: center;
height: 44px;
@ -247,11 +288,6 @@ export default {
margin: 14px !important;
}
}
// make sure to respect readonly on radio/checkbox
input[readonly] {
pointer-events: none;
}
}
// Using type to have a higher order than the input styling of server
@ -265,4 +301,12 @@ export default {
border-radius: 0;
}
input.question__radio,
input.question__checkbox {
z-index: -1;
// make sure browser warnings are properly
// displayed at the correct location
left: 22px;
}
</style>

View file

@ -32,7 +32,7 @@
<input ref="input"
:aria-label="t('forms', 'A short answer for the question “{text}”', { text })"
:placeholder="t('forms', 'Short answer text')"
:readonly="edit"
:required="true /* TODO: implement required option */"
:value="values[0]"
class="question__input"
maxlength="256"

View file

@ -57,8 +57,7 @@ __webpack_nonce__ = btoa(getRequestToken())
// eslint-disable-next-line
__webpack_public_path__ = generateFilePath('forms', '', 'js/')
/* eslint-disable-next-line no-new */
new Vue({
export default new Vue({
el: '#content',
// eslint-disable-next-line vue/match-component-file-name
name: 'FormsRoot',

View file

@ -38,12 +38,16 @@ export default {
icon: 'icon-answer-multiple',
label: t('forms', 'Multiple choice'),
unique: true,
// Define conditions where this questions is not ok
validate: question => question.options.length > 0,
},
multiple: {
component: QuestionMultiple,
icon: 'icon-answer-checkbox',
label: t('forms', 'Checkboxes'),
// Define conditions where this questions is not ok
validate: question => question.options.length > 0,
},
short: {

36
src/submit.js Normal file
View file

@ -0,0 +1,36 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
import { translate, translatePlural } from '@nextcloud/l10n'
import Vue from 'vue'
import Fill from './views/Submit'
Vue.prototype.t = translate
Vue.prototype.n = translatePlural
export default new Vue({
el: '#content',
// eslint-disable-next-line vue/match-component-file-name
name: 'FormsSubmitRoot',
render: h => h(Fill),
})

View file

@ -1,31 +0,0 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @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 <http://www.gnu.org/licenses/>.
-
-->
<template>
<span>TODO</span>
</template>
<script>
export default {
name: 'Fill',
}
</script>

199
src/views/Submit.vue Normal file
View file

@ -0,0 +1,199 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @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 <http://www.gnu.org/licenses/>.
-
-->
<template>
<Content app-name="forms">
<AppContent>
<!-- Forms title & description-->
<header>
<h3 id="form-title">
{{ form.title }}
</h3>
<p v-if="!loading && !success" id="form-desc">
{{ form.description }}
</p>
</header>
<!-- Questions list -->
<form v-if="!loading && !success" @submit.prevent="onSubmit">
<ul>
<Questions
:is="answerTypes[question.type].component"
v-for="(question, index) in validQuestions"
ref="questions"
:key="question.id"
:read-only="true"
:model="answerTypes[question.type]"
:index="index + 1"
v-bind="question"
:values.sync="answers[question.id]" />
</ul>
<input class="primary"
type="submit"
:value="t('forms', 'Submit')"
:disabled="loading"
:aria-label="t('forms', 'Submit form')">
</form>
<EmptyContent v-else-if="loading" icon="icon-loading">
{{ t('forms', 'Submitting form …') }}
</EmptyContent>
<EmptyContent v-else-if="success" icon="icon-checkmark">
{{ t('forms', 'Thank you for completing the survey!') }}
</EmptyContent>
</AppContent>
</Content>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import axios from '@nextcloud/axios'
import AppContent from '@nextcloud/vue/dist/Components/AppContent'
import Content from '@nextcloud/vue/dist/Components/Content'
import answerTypes from '../models/AnswerTypes'
import EmptyContent from '../components/EmptyContent'
import Question from '../components/Questions/Question'
import QuestionLong from '../components/Questions/QuestionLong'
import QuestionShort from '../components/Questions/QuestionShort'
import QuestionMultiple from '../components/Questions/QuestionMultiple'
export default {
name: 'Submit',
components: {
AppContent,
Content,
EmptyContent,
Question,
QuestionLong,
QuestionShort,
QuestionMultiple,
},
data() {
return {
form: loadState('forms', 'form'),
answerTypes,
answers: {},
loading: false,
success: false,
}
},
computed: {
validQuestions() {
return this.form.questions.filter(question => {
// All questions must have a valid title
if (question.text && question.text.trim() === '') {
return false
}
// If specific conditions provided, test against them
if ('validate' in answerTypes[question.type]) {
return answerTypes[question.type].validate(question)
}
return true
})
},
},
methods: {
/**
* Submit the form after the browser validated it 🚀
*/
async onSubmit() {
this.loading = true
try {
await axios.post(generateUrl('/apps/forms/api/v1/submissions/insert'), {
formId: this.form.id,
answers: this.answers,
})
this.success = true
} catch (error) {
console.error(error)
showError(t('forms', 'There was an error submitting the form'))
} finally {
this.loading = false
}
},
},
}
</script>
<style lang="scss" scoped>
// Replace with new vue components release
#app-content,
#app-content-vue {
display: flex;
align-items: center;
flex-direction: column;
header,
form {
width: 100%;
max-width: 750px;
display: flex;
flex-direction: column;
}
// Title & description header
header {
margin: 44px;
#form-title,
#form-desc {
width: 100%;
margin: 16px 0; // aerate the header
padding: 0 16px;
border: none;
}
#form-title {
font-size: 2em;
font-weight: bold;
padding-left: 14px; // align with description (compensate font size diff)
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#form-desc {
min-height: 60px;
max-height: 200px;
margin-top: 0;
resize: none;
}
}
form {
input[type=submit] {
align-self: flex-end;
margin: 5px;
padding: 10px 20px;
}
}
}
</style>

31
templates/expired.php Normal file
View file

@ -0,0 +1,31 @@
<?php
/**
* @copyright Copyright (c) 2019 Inigo Jiron <ijiron@terpmail.umd.edu>
*
* @author Inigo Jiron <ijiron@terpmail.umd.edu>
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
?>
<div id="emptycontent" class="">
<div class="icon-forms"></div>
<h2><?php p($l->t('Form Expired')); ?></h2>
<p><?php p($l->t('This Form has expired and is no longer taking answers')); ?></p>
</div>

View file

@ -1,35 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2019 Inigo Jiron <ijiron@terpmail.umd.edu>
*
* @author Inigo Jiron <ijiron@terpmail.umd.edu>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
use OCP\Util;
Util::addStyle('forms', 'main');
?>
<div id="emptycontent" class="">
<div class="icon-forms"></div>
<h1>
<?php p($l->t('Form Expired')); ?>
</h1>
<h2>
<?php p($l->t('This Form has expired and is no longer taking answers.')); ?>
</h2>
</div>

View file

@ -1,28 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2017 Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @author Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
* @author René Gieling <github@dartcafe.de>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
?>
<?php \OCP\Util::addScript('forms', 'forms'); ?>
<div id="app-forms" value = 'forms'></div>

View file

@ -1,35 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2017 Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @author Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
use OCP\Util;
Util::addStyle('forms', 'main');
?>
<div id="emptycontent" class="">
<div class="icon-forms"></div>
<h1>
<?php p($l->t('Access denied')); ?>
</h1>
<h2>
<?php p($l->t('You are not allowed to view this form or the form does not exist.')); ?>
</h2>
</div>

View file

@ -1,35 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2017 Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @author Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
use OCP\Util;
Util::addStyle('forms', 'main');
?>
<div id="emptycontent" class="">
<div class="icon-forms"></div>
<h1>
<?php p($l->t('Access denied')); ?>
</h1>
<h2>
<?php p($l->t('You are not allowed to edit this form or the form does not exist.')); ?>
</h2>
</div>

View file

@ -1,35 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2017 Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @author Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
use OCP\Util;
Util::addStyle('forms', 'main');
?>
<div id="emptycontent" class="">
<div class="icon-forms"></div>
<h1>
<?php p($l->t('Access denied')); ?>
</h1>
<h2>
<?php p($l->t('You are either not allowed to delete this form or it doesn\'t exist.')); ?>
</h2>
</div>

31
templates/notfound.php Normal file
View file

@ -0,0 +1,31 @@
<?php
/**
* @copyright Copyright (c) 2019 Inigo Jiron <ijiron@terpmail.umd.edu>
*
* @author Inigo Jiron <ijiron@terpmail.umd.edu>
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
?>
<div id="emptycontent" class="">
<div class="icon-forms"></div>
<h2><?php p($l->t('Form not found')); ?></h2>
<p><?php p($l->t('This form does not exists')); ?></p>
</div>

View file

@ -1,45 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2017 Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @author Vinzenz Rosenkranz <vinzenz.rosenkranz@gmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
use OCP\Util;
Util::addStyle('forms', 'submit');
Util::addScript('forms', 'submit');
Util::addScript('forms', 'survey.jquery.min');
/** @var \OCA\Forms\Db\Form $form */
$form = $_['form'];
/** @var OCA\Forms\Db\Question[] $questions */
$questions = $_['questions'];
?>
<?php if ($form->getIsAnonymous()):?>
*NOTE: This form is anonymous
<?php endif?>
<div id="surveyContainer"
form="<?php echo htmlentities(json_encode($form->read()))?>"
questions="<?php echo htmlentities(json_encode($questions))?>"
></div>

View file

@ -1,14 +1,23 @@
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const webpack = require('webpack')
const StyleLintPlugin = require('stylelint-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const appName = process.env.npm_package_name.toString()
const appVersion = process.env.npm_package_version.toString()
console.info('Building', appName, appVersion, '\n')
module.exports = {
entry: path.join(__dirname, 'src', 'main.js'),
entry: {
forms: path.resolve(path.join('src', 'main.js')),
submit: path.resolve(path.join('src', 'submit.js')),
},
output: {
path: path.resolve(__dirname, './js'),
path: path.resolve('./js'),
publicPath: '/js/',
filename: 'forms.js',
chunkFilename: 'chunks/[name].js',
filename: `[name].js`,
chunkFilename: `${appName}.[name].js?v=[contenthash]`,
},
module: {
rules: [
@ -36,18 +45,14 @@ module.exports = {
loader: 'babel-loader',
exclude: /node_modules/,
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'url-loader',
options: {
limit: 8192,
},
},
],
},
plugins: [
new VueLoaderPlugin(),
new StyleLintPlugin(),
// Make appName & appVersion available as a constant
new webpack.DefinePlugin({ appName }),
new webpack.DefinePlugin({ appVersion }),
],
resolve: {
extensions: ['*', '.js', '.vue'],