commit
c6b515fa3d
|
@ -54,8 +54,12 @@ return [
|
|||
['name' => 'api#updateOption', 'url' => '/api/v1/option/update', 'verb' => 'POST'],
|
||||
['name' => 'api#deleteOption', 'url' => '/api/v1/option/{id}', 'verb' => 'DELETE'],
|
||||
|
||||
// Submissions
|
||||
['name' => 'api#getSubmissions', 'url' => '/api/v1/submissions/{hash}', 'verb' => 'GET'],
|
||||
['name' => 'api#insertSubmission', 'url' => '/api/v1/submissions/insert', 'verb' => 'POST'],
|
||||
['name' => 'api#deleteAllSubmissions', 'url' => '/api/v1/submissions/{formId}', 'verb' => 'DELETE'],
|
||||
|
||||
['name' => 'api#insertSubmission', 'url' => '/api/v1/submission/insert', 'verb' => 'POST'],
|
||||
['name' => 'api#deleteSubmission', 'url' => '/api/v1/submission/{id}', 'verb' => 'DELETE'],
|
||||
|
||||
['name' => 'system#get_site_users_and_groups', 'url' => '/get/siteusers', 'verb' => 'POST'],
|
||||
]
|
||||
|
|
|
@ -42,8 +42,11 @@ use OCP\AppFramework\Controller;
|
|||
use OCP\AppFramework\Db\IMapperException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\ILogger;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Security\ISecureRandom;
|
||||
|
||||
class ApiController extends Controller {
|
||||
|
@ -67,9 +70,15 @@ class ApiController extends Controller {
|
|||
/** @var ILogger */
|
||||
private $logger;
|
||||
|
||||
/** @var IL10N */
|
||||
private $l10n;
|
||||
|
||||
/** @var IUserSession */
|
||||
private $userSession;
|
||||
|
||||
|
||||
/** @var IUserManager */
|
||||
private $userManager;
|
||||
|
||||
/** @var FormsService */
|
||||
private $formsService;
|
||||
|
||||
|
@ -77,17 +86,20 @@ class ApiController extends Controller {
|
|||
IRequest $request,
|
||||
$userId, // TODO remove & replace with userSession below.
|
||||
IUserSession $userSession,
|
||||
IUserManager $userManager,
|
||||
FormMapper $formMapper,
|
||||
SubmissionMapper $submissionMapper,
|
||||
AnswerMapper $answerMapper,
|
||||
QuestionMapper $questionMapper,
|
||||
OptionMapper $optionMapper,
|
||||
ILogger $logger,
|
||||
IL10N $l10n,
|
||||
FormsService $formsService) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->appName = $appName;
|
||||
$this->userId = $userId;
|
||||
$this->userSession = $userSession;
|
||||
$this->userManager = $userManager;
|
||||
$this->formMapper = $formMapper;
|
||||
$this->questionMapper = $questionMapper;
|
||||
$this->optionMapper = $optionMapper;
|
||||
|
@ -96,6 +108,7 @@ class ApiController extends Controller {
|
|||
$this->questionMapper = $questionMapper;
|
||||
$this->optionMapper = $optionMapper;
|
||||
$this->logger = $logger;
|
||||
$this->l10n = $l10n;
|
||||
$this->formsService = $formsService;
|
||||
}
|
||||
|
||||
|
@ -553,6 +566,25 @@ class ApiController extends Controller {
|
|||
return new Http\JSONResponse($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*/
|
||||
private function getAnswers(int $submissionId): array {
|
||||
try {
|
||||
$answerEntities = $this->answerMapper->findBySubmission($submissionId);
|
||||
} catch (DoesNotExistException $e) {
|
||||
//Just ignore, if no Data. Returns empty Answers-Array
|
||||
}
|
||||
|
||||
// Load Answer-Data
|
||||
$answers = [];
|
||||
foreach ($answerEntities as $answerEntity) {
|
||||
$answers[] = $answerEntity->read();
|
||||
}
|
||||
|
||||
return $answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*/
|
||||
|
@ -569,24 +601,54 @@ class ApiController extends Controller {
|
|||
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$submissionList = $this->submissionMapper->findByForm($form->getId());
|
||||
foreach ($submissionList as $submissionEntity) {
|
||||
$answerList = $this->answerMapper->findBySubmission($submissionEntity->id);
|
||||
foreach ($answerList as $answerEntity) {
|
||||
$answer = $answerEntity->read();
|
||||
//Temporary Adapt Data to be usable by old Results-View
|
||||
$answer['userId'] = $submissionEntity->getUserId();
|
||||
|
||||
$question = $this->questionMapper->findById($answer['questionId']);
|
||||
$answer['questionText'] = $question->getText();
|
||||
$answer['questionType'] = $question->getType();
|
||||
|
||||
$result[] = $answer;
|
||||
}
|
||||
try {
|
||||
$submissionEntities = $this->submissionMapper->findByForm($form->getId());
|
||||
} catch (DoesNotExistException $e) {
|
||||
//Just ignore, if no Data. Returns empty Submissions-Array
|
||||
}
|
||||
|
||||
return new Http\JSONResponse($result);
|
||||
$submissions = [];
|
||||
foreach ($submissionEntities as $submissionEntity) {
|
||||
// Load Submission-Data & corresponding Answers
|
||||
$submission = $submissionEntity->read();
|
||||
$submission['answers'] = $this->getAnswers($submission['id']);
|
||||
|
||||
// Append Display Name
|
||||
if (substr($submission['userId'], 0, 10) === 'anon-user-') {
|
||||
// Anonymous User
|
||||
$submission['userDisplayName'] = $this->l10n->t('anonymous user');
|
||||
} else {
|
||||
$userEntity = $this->userManager->get($submission['userId']);
|
||||
|
||||
if ($userEntity instanceof IUser) {
|
||||
$submission['userDisplayName'] = $userEntity->getDisplayName();
|
||||
} else {
|
||||
// Fallback, should not occur regularly.
|
||||
$submission['userDisplayName'] = $submission['userId'];
|
||||
}
|
||||
}
|
||||
|
||||
// Add to returned List of Submissions
|
||||
$submissions[] = $submission;
|
||||
}
|
||||
|
||||
// Load question-texts, including deleted ones.
|
||||
try {
|
||||
$questionEntities = $this->questionMapper->findByForm($form->getId());
|
||||
} catch (DoesNotExistException $e) {
|
||||
//handle silently
|
||||
}
|
||||
$questions = [];
|
||||
foreach ($questionEntities as $questionEntity) {
|
||||
$questions[] = $questionEntity->read();
|
||||
}
|
||||
|
||||
$response = [
|
||||
'submissions' => $submissions,
|
||||
'questions' => $questions,
|
||||
];
|
||||
|
||||
return new Http\JSONResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -680,4 +742,57 @@ class ApiController extends Controller {
|
|||
|
||||
return new Http\JSONResponse([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*/
|
||||
public function deleteSubmission(int $id): Http\JSONResponse {
|
||||
$this->logger->debug('Delete Submission: {id}', [
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
try {
|
||||
$submission = $this->submissionMapper->findById($id);
|
||||
$form = $this->formMapper->findById($submission->getFormId());
|
||||
} catch (IMapperException $e) {
|
||||
$this->logger->debug('Could not find form or submission');
|
||||
return new Http\JSONResponse([], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($form->getOwnerId() !== $this->userId) {
|
||||
$this->logger->debug('This form is not owned by the current user');
|
||||
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Delete submission (incl. Answers)
|
||||
$this->submissionMapper->delete($submission);
|
||||
|
||||
return new Http\JSONResponse($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*/
|
||||
public function deleteAllSubmissions(int $formId): Http\JSONResponse {
|
||||
$this->logger->debug('Delete all submissions to form: {formId}', [
|
||||
'formId' => $formId,
|
||||
]);
|
||||
|
||||
try {
|
||||
$form = $this->formMapper->findById($formId);
|
||||
} catch (IMapperException $e) {
|
||||
$this->logger->debug('Could not find form');
|
||||
return new Http\JSONResponse([], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($form->getOwnerId() !== $this->userId) {
|
||||
$this->logger->debug('This form is not owned by the current user');
|
||||
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Delete all submissions (incl. Answers)
|
||||
$this->submissionMapper->deleteByForm($formId);
|
||||
|
||||
return new Http\JSONResponse($id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,11 +57,31 @@ class SubmissionMapper extends QBMapper {
|
|||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT))
|
||||
);
|
||||
)
|
||||
//Newest submissions first
|
||||
->orderBy('timestamp', 'DESC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Integer $id
|
||||
* @return Submission
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException if more than one result
|
||||
* @throws \OCP\AppFramework\Db\DoesNotExistException if not found
|
||||
*/
|
||||
public function findById(int $id): Submission {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
|
||||
);
|
||||
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $formId
|
||||
* @throws DoesNotExistException if not found
|
||||
|
|
59
src/components/Results/Answer.vue
Normal file
59
src/components/Results/Answer.vue
Normal file
|
@ -0,0 +1,59 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2020 Jonas Rittershofer <jotoeri@users.noreply.github.com>
|
||||
-
|
||||
- @author Jonas Rittershofer <jotoeri@users.noreply.github.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>
|
||||
<div class="answer">
|
||||
<h4 class="question-text">
|
||||
{{ question.text }}
|
||||
</h4>
|
||||
<p>{{ answer.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Answer',
|
||||
|
||||
props: {
|
||||
answer: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
question: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.answer {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
|
||||
.question-text {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
129
src/components/Results/Submission.vue
Normal file
129
src/components/Results/Submission.vue
Normal file
|
@ -0,0 +1,129 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2020 Jonas Rittershofer <jotoeri@users.noreply.github.com>
|
||||
-
|
||||
- @author Jonas Rittershofer <jotoeri@users.noreply.github.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>
|
||||
<div class="section submission">
|
||||
<div class="submission-head">
|
||||
<h3>
|
||||
{{ t('forms', 'Response by {userDisplayName}', { userDisplayName: submission.userDisplayName }) }}
|
||||
</h3>
|
||||
<Actions class="submission-menu" :force-menu="true">
|
||||
<ActionButton icon="icon-delete" @click="onDelete">
|
||||
{{ t('forms', 'Delete this response') }}
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
</div>
|
||||
<p class="submission-date">
|
||||
{{ submissionDateTime }}
|
||||
</p>
|
||||
|
||||
<Answer
|
||||
v-for="answer in squashedAnswers"
|
||||
:key="answer.questionId"
|
||||
:answer="answer"
|
||||
:question="questionToAnswer(answer.questionId)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Actions from '@nextcloud/vue/dist/Components/Actions'
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import moment from '@nextcloud/moment'
|
||||
|
||||
import Answer from './Answer'
|
||||
|
||||
export default {
|
||||
name: 'Submission',
|
||||
|
||||
components: {
|
||||
Actions,
|
||||
ActionButton,
|
||||
Answer,
|
||||
},
|
||||
|
||||
props: {
|
||||
submission: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
questions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
submissionDateTime() {
|
||||
return moment(this.submission.timestamp, 'X').format('LLLL')
|
||||
},
|
||||
squashedAnswers() {
|
||||
const squashedArray = []
|
||||
|
||||
this.submission.answers.forEach(answer => {
|
||||
const index = squashedArray.findIndex(ansSq => ansSq.questionId === answer.questionId)
|
||||
if (index > -1) {
|
||||
squashedArray[index].text = squashedArray[index].text.concat('; ' + answer.text)
|
||||
} else {
|
||||
squashedArray.push(answer)
|
||||
}
|
||||
})
|
||||
|
||||
return squashedArray
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
questionToAnswer(questionId) {
|
||||
return this.questions.find(question => question.id === questionId)
|
||||
},
|
||||
|
||||
onDelete() {
|
||||
this.$emit('delete')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.submission {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
|
||||
&-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
h3 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&-menu {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&-date {
|
||||
color: var(--color-text-lighter);
|
||||
margin-top: -8px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -51,7 +51,15 @@ $top-bar-height: 60px;
|
|||
|
||||
button {
|
||||
cursor: pointer;
|
||||
&:not(:first-child) {
|
||||
min-height: 44px;
|
||||
|
||||
// Fix button having too little spacing left and right of text
|
||||
&:not(.button-small) {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
&.button-small {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
|
|
|
@ -1,354 +0,0 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2018 René Gieling <github@dartcafe.de>
|
||||
-
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="header" class="wrapper group-master table-row table-header">
|
||||
<div class="wrapper group-1">
|
||||
<div class="wrapper group-1-1">
|
||||
<div class="name">
|
||||
{{ "Name" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper group-1-1">
|
||||
<div class="question">
|
||||
{{ "Question #" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper group-1-1">
|
||||
<div class="questionText">
|
||||
{{ "Question" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper group-2">
|
||||
<div class="ans">
|
||||
{{ "Response" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="wrapper table-row table-body group-master">
|
||||
<div class="wrapper group-1">
|
||||
<div class="wrapper group-1-1">
|
||||
<div class="name">
|
||||
{{ participants }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper group-1-1">
|
||||
<div class="question">
|
||||
{{ questionNum }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper group-1-1">
|
||||
<div class="questionText">
|
||||
{{ questionText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper group-2">
|
||||
<div class="ans">
|
||||
{{ answerText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
header: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
answer: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
openedMenu: false,
|
||||
hostName: this.$route.query.page,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
participants() {
|
||||
return this.answer.userId
|
||||
},
|
||||
questionText() {
|
||||
return this.answer.questionText
|
||||
},
|
||||
answerText() {
|
||||
return this.answer.text
|
||||
},
|
||||
questionNum() {
|
||||
return this.answer.questionId
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
$row-padding: 15px;
|
||||
$table-padding: 4px;
|
||||
|
||||
$date-width: 120px;
|
||||
$participants-width: 95px;
|
||||
$group-2-2-width: max($date-width, $participants-width);
|
||||
|
||||
$owner-width: 140px;
|
||||
$access-width: 44px;
|
||||
$group-2-1-width: max($access-width, $date-width);
|
||||
$group-2-width: $owner-width + $group-2-1-width + $group-2-2-width;
|
||||
|
||||
$action-width: 44px;
|
||||
$thumbnail-width: 44px;
|
||||
$thumbnail-icon-width: 32px;
|
||||
$name-width: 150px;
|
||||
$description-width: 150px;
|
||||
$group-1-1-width: max($name-width, $description-width);
|
||||
$group-1-width: $thumbnail-width + $group-1-1-width + $action-width;
|
||||
|
||||
$group-master-width: max($group-1-width, $group-2-width);
|
||||
|
||||
$mediabreak-1: ($group-1-width + $owner-width + $access-width + $date-width + $date-width + $participants-width + $row-padding * 2);
|
||||
$mediabreak-2: ($group-1-width + $group-2-width + $row-padding * 2);
|
||||
$mediabreak-3: $group-1-width + $owner-width + max($group-2-1-width, $group-2-2-width) + $row-padding *2 ;
|
||||
|
||||
.table-row {
|
||||
width: 100%;
|
||||
padding-left: $row-padding;
|
||||
padding-right: $row-padding;
|
||||
|
||||
line-height: 2em;
|
||||
transition: background-color 0.3s ease;
|
||||
background-color: var(--color-main-background);
|
||||
min-height: 4em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
&.table-header {
|
||||
opacity: 0.5;
|
||||
.name, .description {
|
||||
padding-left: ($thumbnail-width + $table-padding *2);
|
||||
}
|
||||
.owner {
|
||||
padding-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&.table-body {
|
||||
&:hover, &:focus, &:active, &.mouseOver {
|
||||
transition: background-color 0.3s ease;
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
.icon-more {
|
||||
right: 14px;
|
||||
opacity: 0.3;
|
||||
cursor: pointer;
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.symbol {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: $name-width;
|
||||
}
|
||||
|
||||
.description {
|
||||
width: $description-width;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.name, .description {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: $action-width;
|
||||
position: relative;
|
||||
overflow: initial;
|
||||
}
|
||||
|
||||
.access {
|
||||
width: $access-width;
|
||||
}
|
||||
|
||||
.owner {
|
||||
width: $owner-width;
|
||||
}
|
||||
|
||||
.created {
|
||||
width: $date-width;
|
||||
}
|
||||
|
||||
.expiry {
|
||||
width: $date-width;
|
||||
&.expired {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.group-1, .group-1-1 {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.group-1-1 {
|
||||
flex-direction: column;
|
||||
width: $group-1-1-width;
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: ($mediabreak-1) ) {
|
||||
.group-1 {
|
||||
width: $group-1-width;
|
||||
}
|
||||
.group-2-1, .group-2-2 {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.created {
|
||||
width: $group-2-1-width;
|
||||
}
|
||||
.expiry, .participants {
|
||||
width: $group-2-2-width;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: ($mediabreak-2) ) {
|
||||
.table-row {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.group-2-1 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: ($mediabreak-3) ) {
|
||||
.group-2 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding-right: 4px;
|
||||
font-size: 0;
|
||||
background-color: var(--color-text-light);
|
||||
&.dateform {
|
||||
mask-image: var(--icon-calendar-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-calendar-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.textform {
|
||||
mask-image: var(--icon-organization-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-organization-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.expired {
|
||||
background-color: var(--color-background-darker);
|
||||
}
|
||||
&.access {
|
||||
display: inherit;
|
||||
&.hidden {
|
||||
mask-image: var(--icon-password-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-password-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.public {
|
||||
mask-image: var(--icon-link-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-link-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.select {
|
||||
mask-image: var(--icon-share-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-share-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
&.registered {
|
||||
mask-image: var(--icon-group-000) no-repeat 50% 50%;
|
||||
-webkit-mask: var(--icon-group-000) no-repeat 50% 50%;
|
||||
mask-size: 16px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.icon-voted {
|
||||
background-image: var(--icon-checkmark-fff);
|
||||
}
|
||||
|
||||
.app-navigation-entry-utils-counter {
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
font-size: 9pt;
|
||||
line-height: 44px;
|
||||
padding: 0 12px;
|
||||
padding-right: 0 !important;
|
||||
// min-width: 25px;
|
||||
&.highlighted {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
span {
|
||||
padding: 2px 5px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.symbol.icon-voted {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 16px;
|
||||
background-size: 0;
|
||||
min-width: 8px;
|
||||
min-height: 8px;
|
||||
background-color: var(--color-success);
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
x
|
|
@ -38,6 +38,7 @@
|
|||
</button>
|
||||
<button v-tooltip="t('forms', 'Toggle settings')"
|
||||
:aria-label="t('forms', 'Toggle settings')"
|
||||
class="button-small"
|
||||
@click="toggleSidebar">
|
||||
<span class="icon-menu-sidebar" role="img" />
|
||||
</button>
|
||||
|
|
|
@ -39,8 +39,18 @@
|
|||
|
||||
<header v-if="!noSubmissions">
|
||||
<h2>{{ t('forms', 'Responses for {title}', { title: form.title }) }}</h2>
|
||||
<div v-for="sum in stats" :key="sum">
|
||||
{{ sum }}
|
||||
<div>
|
||||
<button id="exportButton" @click="download">
|
||||
<span class="icon-download" role="img" />
|
||||
{{ t('forms', 'Export to CSV') }}
|
||||
</button>
|
||||
<Actions class="results-menu"
|
||||
:aria-label="t('forms', 'Options')"
|
||||
:force-menu="true">
|
||||
<ActionButton icon="icon-delete" @click="deleteAllSubmissions">
|
||||
{{ t('forms', 'Delete all responses') }}
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
@ -56,35 +66,28 @@
|
|||
</section>
|
||||
|
||||
<section v-else>
|
||||
<button id="exportButton" class="primary" @click="download">
|
||||
<span class="icon-download-white" role="img" />
|
||||
{{ t('forms', 'Export to CSV') }}
|
||||
</button>
|
||||
<transition-group
|
||||
name="list"
|
||||
tag="div"
|
||||
class="table">
|
||||
<ResultItem
|
||||
key="0"
|
||||
:header="true" />
|
||||
<ResultItem
|
||||
v-for="answer in answers"
|
||||
:key="answer.id"
|
||||
:answer="answer" />
|
||||
</transition-group>
|
||||
<Submission
|
||||
v-for="submission in submissions"
|
||||
:key="submission.id"
|
||||
:submission="submission"
|
||||
:questions="questions"
|
||||
@delete="deleteSubmission(submission.id)" />
|
||||
</section>
|
||||
</AppContent>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { Parser } from 'json2csv'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import Actions from '@nextcloud/vue/dist/Components/Actions'
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import AppContent from '@nextcloud/vue/dist/Components/AppContent'
|
||||
import axios from '@nextcloud/axios'
|
||||
import json2csvParser from 'json2csv'
|
||||
import moment from '@nextcloud/moment'
|
||||
|
||||
import EmptyContent from '../components/EmptyContent'
|
||||
import ResultItem from '../components/resultItem'
|
||||
import Submission from '../components/Results/Submission'
|
||||
import TopBar from '../components/TopBar'
|
||||
import ViewsMixin from '../mixins/ViewsMixin'
|
||||
|
||||
|
@ -92,9 +95,11 @@ export default {
|
|||
name: 'Results',
|
||||
|
||||
components: {
|
||||
Actions,
|
||||
ActionButton,
|
||||
AppContent,
|
||||
EmptyContent,
|
||||
ResultItem,
|
||||
Submission,
|
||||
TopBar,
|
||||
},
|
||||
|
||||
|
@ -103,45 +108,14 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
loadingResults: true,
|
||||
answers: [],
|
||||
submissions: [],
|
||||
questions: [],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
stats() {
|
||||
const sums = []
|
||||
|
||||
if (this.answers != null) {
|
||||
const uniqueAns = []
|
||||
const uniqueQs = []
|
||||
const ansToQ = new Map()
|
||||
for (let i = 0; i < this.answers.length; i++) {
|
||||
if (this.answers[i].questionType === 'radiogroup' || this.answers[i].questionType === 'dropdown') {
|
||||
if (uniqueAns.includes(this.answers[i].text) === false) {
|
||||
uniqueAns.push(this.answers[i].text)
|
||||
ansToQ.set(this.answers[i].text, this.answers[i].questionId)
|
||||
}
|
||||
if (uniqueQs.includes(this.answers[i].questionId) === false) {
|
||||
uniqueQs.push(this.answers[i].questionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < uniqueAns.length; i++) {
|
||||
sums[i] = 0
|
||||
}
|
||||
for (let i = 0; i < this.answers.length; i++) {
|
||||
sums[uniqueAns.indexOf(this.answers[i].text)]++
|
||||
}
|
||||
for (let i = 0; i < sums.length; i++) {
|
||||
sums[i] = 'Question ' + ansToQ.get(uniqueAns[i]) + ': ' + (sums[i] / ((this.answers.length / uniqueQs.length)) * 100).toFixed(2) + '%' + ' of respondents voted for answer choice: ' + uniqueAns[i]
|
||||
}
|
||||
}
|
||||
|
||||
return sums.sort()
|
||||
},
|
||||
|
||||
noSubmissions() {
|
||||
return this.answers && this.answers.length === 0
|
||||
return this.submissions && this.submissions.length === 0
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -150,7 +124,6 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
|
||||
showEdit() {
|
||||
this.$router.push({
|
||||
name: 'edit',
|
||||
|
@ -168,8 +141,9 @@ export default {
|
|||
const response = await axios.get(generateUrl('/apps/forms/api/v1/submissions/{hash}', {
|
||||
hash: this.form.hash,
|
||||
}))
|
||||
this.answers = response.data
|
||||
console.debug(this.answers)
|
||||
this.submissions = response.data.submissions
|
||||
this.questions = response.data.questions
|
||||
console.debug(this.submissions)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showError(t('forms', 'There was an error while loading results'))
|
||||
|
@ -178,50 +152,91 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
download() {
|
||||
this.loading = true
|
||||
axios.get(generateUrl('apps/forms/get/form/' + this.$route.params.hash))
|
||||
.then((response) => {
|
||||
this.json2csvParser = ['userId', 'questionId', 'questionText', 'Answer'] // TODO Is this one necessary??
|
||||
const formattedAns = []
|
||||
this.answers.forEach(ans => {
|
||||
formattedAns.push({
|
||||
userId: ans['userId'],
|
||||
questionId: ans['questionId'],
|
||||
questionText: ans['questionText'],
|
||||
answer: ans['text'],
|
||||
})
|
||||
})
|
||||
const element = document.createElement('a')
|
||||
element.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(json2csvParser.parse(formattedAns)))
|
||||
element.setAttribute('download', response.data.title + '.csv')
|
||||
async deleteSubmission(id) {
|
||||
this.loadingResults = true
|
||||
|
||||
element.style.display = 'none'
|
||||
document.body.appendChild(element)
|
||||
element.click()
|
||||
document.body.removeChild(element)
|
||||
this.loading = false
|
||||
}, (error) => {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.log(error.response)
|
||||
this.loading = false
|
||||
try {
|
||||
await axios.delete(generateUrl('/apps/forms/api/v1/submission/{id}', { id }))
|
||||
const index = this.submissions.findIndex(search => search.id === id)
|
||||
this.submissions.splice(index, 1)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showError(t('forms', 'There was an error while removing the submission'))
|
||||
} finally {
|
||||
this.loadingResults = false
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAllSubmissions() {
|
||||
if (!confirm(t('forms', 'Are you sure you want to delete all submissions from this form?'))) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loadingResults = true
|
||||
try {
|
||||
await axios.delete(generateUrl('/apps/forms/api/v1/submissions/{formId}', { formId: this.form.id }))
|
||||
this.submissions = []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showError(t('forms', 'There was an error while removing the submissions'))
|
||||
} finally {
|
||||
this.loadingResults = false
|
||||
}
|
||||
},
|
||||
|
||||
download() {
|
||||
this.loadingResults = true
|
||||
|
||||
const parser = new Parser({
|
||||
delimiter: ',',
|
||||
})
|
||||
|
||||
const formattedSubmissions = []
|
||||
this.submissions.forEach(submission => {
|
||||
const formattedSubmission = {
|
||||
userDisplayName: submission.userDisplayName,
|
||||
timestamp: moment(submission.timestamp, 'X').format('L LT'),
|
||||
}
|
||||
|
||||
submission.answers.forEach(answer => {
|
||||
const questionText = this.questions.find(question => question.id === answer.questionId).text
|
||||
if (questionText in formattedSubmission) {
|
||||
formattedSubmission[questionText] = formattedSubmission[questionText].concat('; ').concat(answer.text)
|
||||
} else {
|
||||
formattedSubmission[questionText] = answer.text
|
||||
}
|
||||
})
|
||||
formattedSubmissions.push(formattedSubmission)
|
||||
})
|
||||
|
||||
const element = document.createElement('a')
|
||||
element.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(parser.parse(formattedSubmissions)))
|
||||
element.setAttribute('download', this.form.title + '.csv')
|
||||
element.style.display = 'none'
|
||||
document.body.appendChild(element)
|
||||
element.click()
|
||||
document.body.removeChild(element)
|
||||
|
||||
this.loadingResults = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table {
|
||||
width: 100%;
|
||||
margin-top: 45px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex-wrap: nowrap;
|
||||
h2 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin-top: 32px;
|
||||
padding-left: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#exportButton {
|
||||
width: max-content;
|
||||
padding: 13px 16px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -131,7 +131,7 @@ export default {
|
|||
this.loading = true
|
||||
|
||||
try {
|
||||
await axios.post(generateUrl('/apps/forms/api/v1/submissions/insert'), {
|
||||
await axios.post(generateUrl('/apps/forms/api/v1/submission/insert'), {
|
||||
formId: this.form.id,
|
||||
answers: this.answers,
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue