Add Summary response visualization, fix #314

Signed-off-by: Jan-Christoph Borchardt <hey@jancborchardt.net>
This commit is contained in:
Jan-Christoph Borchardt 2020-06-16 03:34:04 +02:00
parent b7bcbd81d2
commit bdb60d77f1
No known key found for this signature in database
GPG key ID: CBD846FC845CBE17
2 changed files with 302 additions and 25 deletions

View file

@ -0,0 +1,245 @@
<!--
- @copyright Copyright (c) 2020 Jan C. Borchardt https://jancborchardt.net
-
- @author Jan C. Borchardt https://jancborchardt.net
-
- @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 question-summary">
<h3>{{ question.text }}</h3>
<p class="question-summary__detail">
{{ answerTypes[question.type].label }}
</p>
<!-- Answers with countable results for visualization -->
<ol v-if="question.type === 'multiple' || question.type === 'multiple_unique' || question.type === 'dropdown'"
class="question-summary__statistic">
<li v-for="option in questionOptions"
:key="option.id">
<label :for="`option-${option.questionId}-${option.id}`">
{{ option.count }}
<span class="question-summary__statistic-percentage">
({{ option.percentage }}%):
</span>
<span class="question-summary__statistic-text">{{ option.text }}</span>
</label>
<meter :id="`option-${option.questionId}-${option.id}`"
min="0"
:max="submissions.length"
:value="option.count" />
</li>
</ol>
<!-- Text answers are simply listed for now, could be automatically grouped in the future -->
<ul v-else class="question-summary__text">
<li v-for="answer in textAnswers"
:key="answer.id">
{{ answer }}
</li>
</ul>
</div>
</template>
<script>
import answerTypes from '../../models/AnswerTypes'
export default {
name: 'Summary',
props: {
submissions: {
type: Array,
required: true,
},
question: {
type: Object,
required: true,
},
},
data() {
return {
answerTypes,
}
},
computed: {
// For countable questions like multiple choice and checkboxes
questionOptions() {
// Build list of question options
const questionOptionsStats = this.question.options.map(option => ({
...option,
count: 0,
percentage: 0,
}))
// Also record 'No response'
questionOptionsStats.unshift({
text: t('forms', 'No response'),
count: 0,
percentage: 0,
})
// Go through submissions to check which options have how many responses
this.submissions.forEach(submission => {
const answers = submission.answers.filter(answer => answer.questionId === this.question.id)
if (!answers.length) {
// Record 'No response'
questionOptionsStats[0].count++
}
// Check question options to find which needs to be increased
answers.forEach(answer => {
const optionsStatIndex = questionOptionsStats.findIndex(option => option.text === answer.text)
if (optionsStatIndex < 0) {
questionOptionsStats.push({
'text': answer.text,
'count': 1,
'percentage': 0,
})
} else {
questionOptionsStats[optionsStatIndex].count++
}
})
})
// Sort options by response count
questionOptionsStats.sort((object1, object2) => {
if (object1.count === object2.count) {
return 0
}
return object1.count < object2.count ? 1 : -1
})
// Fill percentage values
questionOptionsStats.forEach(questionOptionsStat => {
questionOptionsStat.percentage = Math.round((100 * questionOptionsStat.count) / this.submissions.length)
})
return questionOptionsStats
},
// For text answers like short answer and long text
textAnswers() {
const textAnswers = []
// Also record 'No response'
let noResponseCount = 0
// Go through submissions to check which options have how many responses
this.submissions.forEach(submission => {
const answers = submission.answers.filter(answer => answer.questionId === this.question.id)
if (!answers.length) {
// Record 'No response'
noResponseCount++
}
// Add text answers
answers.forEach(answer => {
textAnswers.push(answer.text)
})
})
// Calculate no response percentage
const noResponsePercentage = Math.round((100 * noResponseCount) / this.submissions.length)
textAnswers.unshift(noResponseCount + ' (' + noResponsePercentage + '%): ' + t('forms', 'No response'))
return textAnswers
},
},
}
</script>
<style lang="scss" scoped>
.question-summary {
padding-left: 16px;
padding-right: 16px;
h3 {
font-weight: bold;
}
&__detail {
color: var(--color-text-lighter);
margin-top: -8px;
}
&__text,
&__statistic {
margin-top: 8px;
}
&__text {
list-style-type: initial;
li {
padding: 4px 0;
&:first-child {
font-weight: bold;
}
}
}
&__statistic {
list-style-type: none;
li {
position: relative;
padding: 8px 0;
label {
cursor: default;
}
&:first-child .question-summary__statistic-text {
font-weight: bold;
}
.question-summary__statistic-percentage {
color: var(--color-text-maxcontrast);
}
meter {
display: block;
width: 100%;
margin-top: 4px;
background: var(--color-background-dark);
height: calc(var(--border-radius) * 2);
border-radius: var(--border-radius);
&::-webkit-meter-bar {
height: calc(var(--border-radius) * 2);
}
// The pseudo-classes of -moz and -webkit have to stay separated even with SCSS, otherwise they dont work
&::-webkit-meter-optimum-value {
background: linear-gradient(40deg, var(--color-primary-element) 0%, var(--color-primary-element-light) 100%);
border-radius: var(--border-radius);
}
&::-moz-meter-bar {
background: linear-gradient(40deg, var(--color-primary-element) 0%, var(--color-primary-element-light) 100%);
border-radius: var(--border-radius);
}
}
}
}
}
</style>

View file

@ -38,7 +38,10 @@
</TopBar>
<header v-if="!noSubmissions">
<h2>{{ t('forms', 'Responses for {title}', { title: form.title }) }}</h2>
<h2>{{ formTitle }}</h2>
<p>{{ t('forms', '{amount} responses', { amount: form.submissions.length }) }}</p>
<!-- View switcher between Summary and Responses -->
<div class="response-actions">
<div class="response-actions__radio">
<input id="show-summary--true"
@ -62,6 +65,8 @@
{{ t('forms', 'Responses') }}
</label>
</div>
<!-- Action menu for CSV export and deletion -->
<Actions class="results-menu"
:aria-label="t('forms', 'Options')"
:force-menu="true">
@ -90,10 +95,16 @@
</EmptyContent>
</section>
<!-- Summary view for visualization -->
<section v-if="!noSubmissions && showSummary">
SUMMARY-CONTENT
<Summary
v-for="question in form.questions"
:key="question.id"
:question="question"
:submissions="form.submissions" />
</section>
<!-- Responses view for individual responses -->
<section v-if="!noSubmissions && !showSummary">
<Submission
v-for="submission in form.submissions"
@ -118,6 +129,7 @@ import moment from '@nextcloud/moment'
import Vue from 'vue'
import EmptyContent from '../components/EmptyContent'
import Summary from '../components/Results/Summary'
import Submission from '../components/Results/Submission'
import TopBar from '../components/TopBar'
import ViewsMixin from '../mixins/ViewsMixin'
@ -133,6 +145,7 @@ export default {
ActionButton,
AppContent,
EmptyContent,
Summary,
Submission,
TopBar,
},
@ -289,33 +302,52 @@ export default {
</script>
<style lang="scss" scoped>
h2 {
font-size: 2em;
font-weight: bold;
margin-top: 32px;
padding-left: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-content header {
h2 {
font-size: 2em;
font-weight: bold;
margin-top: 32px;
padding-left: 14px;
padding-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.response-actions {
display: flex;
align-items: center;
padding-left: 14px;
p {
padding-left: 14px;
}
&__radio {
margin-right: 8px;
.response-actions {
display: flex;
align-items: center;
padding-left: 14px;
&__item {
border-radius: 2px;
padding: 8px 16px;
font-weight: bold;
background-color: var(--color-background-dark);
&__radio {
margin-right: 8px;
&--active {
background-color: var(--color-primary);
color: var(--color-primary-text)
&__item {
border-radius: var(--border-radius-pill);
padding: 8px 16px;
font-weight: bold;
background-color: var(--color-background-dark);
&:first-of-type {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-right: 8px;
}
&:last-of-type {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 8px;
}
&--active {
background-color: var(--color-primary);
color: var(--color-primary-text)
}
}
}
}