Merge pull request 'feature/get-data-from-backend' (#6) from feature/get-data-from-backend into develop-candymat

Reviewed-on: Netzbegruenung/candymat-user-app#6
This commit is contained in:
Christoph Lienhard 2020-08-22 21:16:57 +02:00
commit f239bec4ff
20 changed files with 1133 additions and 4345 deletions

View file

@ -1,7 +1,8 @@
module.exports = { module.exports = {
root: true, root: true,
env: { env: {
node: true node: true,
jest: true
}, },
'extends': [ 'extends': [
'plugin:vue/recommended', 'plugin:vue/recommended',

3
.gitignore vendored
View file

@ -23,3 +23,6 @@ yarn-error.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw* *.sw*
# GraphQl plugin related
postgraphile-schema.graphql

15
.graphqlconfig Normal file
View file

@ -0,0 +1,15 @@
{
"name": "Postgraphile Schema",
"schemaPath": "postgraphile-schema.graphql",
"extensions": {
"endpoints": {
"Remote SWAPI GraphQL Endpoint": {
"url": "http://localhost:5433/graphql",
"headers": {
"user-agent": "JS GraphQL"
},
"introspect": true
}
}
}
}

View file

@ -1,6 +1,6 @@
# CANDYMAT # CANDYMAT
A Vue.js powered, progressive web voting application for upcoming internal elections of Bündnis90/Die Grünen. Candymat is hosted as a service of netzbegruenung e.V. A Vue.js powered, progressive web voting application for upcoming internal elections of Bündnis90/Die Grünen. Candymat is hosted as a service of netzbegruenung e.V.
## Calculation Model ## Calculation Model
@ -16,9 +16,24 @@ This is a Vue.js progressive web application, developed with [`@vue/cli`](https:
| `npm run serve` | Serve with hot reload at localhost:8080 | | `npm run serve` | Serve with hot reload at localhost:8080 |
| `npm run build` | Build for production with minification | | `npm run build` | Build for production with minification |
| `npm run test:unit` | Run all unit tests | | `npm run test:unit` | Run all unit tests |
| `npm run lint` | Runs `standard` over all `.js` and `.vue` files | | `npm run lint` | Runs `standard` over all `.js` and `.vue` files and fixes problems |
| `npm run svg` | Creates all SVG files used in the application | | `npm run svg` | Creates all SVG files used in the application |
| `npm run admin` | Creates `config.yml` for Netlify CMS admin UI | | `npm run admin` | Creates `config.yml` for Netlify CMS admin UI |
### Working with GraphQl backend
As a connector to the backend, `apollo-vue` is used.
Queries are written as `gql` strings.
To have schema hints etc, there is a `.graphqlconfig` file which should help dedicated IDE plugins
to infer the GraphQl schema directly from the (running) backend
(see main project for more information on how a "running backend" is achieved).
For example, the Intellij JS GraphQL plugin will automatically ask to download the schema definition.
### Notes
* To keep the diff to the original euromat source as small as possible certain variables follow a naming convention
which may seem weird at first.
These include
* `party` (better description would be `person`)
## Props ## Props
This user app is based on source code of EUROMAT targeted at european elections. This user app is based on source code of EUROMAT targeted at european elections.

View file

@ -20,7 +20,7 @@ module.exports = {
'jest-serializer-vue' 'jest-serializer-vue'
], ],
testMatch: [ testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' '**/*.test.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
], ],
testURL: 'http://localhost/' testURL: 'http://localhost/'
} }

849
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,12 +5,15 @@
"scripts": { "scripts": {
"serve": "npm run svg && vue-cli-service serve", "serve": "npm run svg && vue-cli-service serve",
"build": "npm run svg && npm run admin && npm run data && vue-cli-service build", "build": "npm run svg && npm run admin && npm run data && vue-cli-service build",
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint --fix",
"test:unit": "vue-cli-service test:unit", "test:unit": "vue-cli-service test:unit",
"svg": "vsvg -s ./src/assets/svg -t ./src/assets/icons", "svg": "vsvg -s ./src/assets/svg -t ./src/assets/icons",
"admin": "node bin/admin-yml" "admin": "node bin/admin-yml"
}, },
"dependencies": { "dependencies": {
"apollo-boost": "^0.4.9",
"graphql": "^15.1.0",
"graphql-tag": "^2.10.3",
"lint-staged": "^8.1.5", "lint-staged": "^8.1.5",
"register-service-worker": "^1.6.2", "register-service-worker": "^1.6.2",
"stylelint": "^10.0.0", "stylelint": "^10.0.0",
@ -18,6 +21,7 @@
"stylelint-processor-html": "^1.0.0", "stylelint-processor-html": "^1.0.0",
"stylelint-webpack-plugin": "^0.10.5", "stylelint-webpack-plugin": "^0.10.5",
"vue": "^2.6.6", "vue": "^2.6.6",
"vue-apollo": "^3.0.3",
"vue-feather-icons": "^4.10.0", "vue-feather-icons": "^4.10.0",
"vue-i18n": "^8.10.0", "vue-i18n": "^8.10.0",
"vue-markdown": "^2.2.4", "vue-markdown": "^2.2.4",
@ -41,7 +45,7 @@
"eslint-plugin-vue": "^5.0.0", "eslint-plugin-vue": "^5.0.0",
"husky": "^1.3.1", "husky": "^1.3.1",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"node-sass": "^4.13.1", "node-sass": "^4.14.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"ora": "^3.4.0", "ora": "^3.4.0",
"sass-loader": "^7.2.0", "sass-loader": "^7.2.0",

View file

@ -37,8 +37,8 @@
</template> </template>
<script> <script>
import { theses } from '@/data'
import { getTranslatedUrl } from '@/i18n/helper' import { getTranslatedUrl } from '@/i18n/helper'
import { apolloThesesQuery, apolloThesesUpdate } from '@/app/euromat/graphqlQueries'
export default { export default {
name: 'Emphasis', name: 'Emphasis',
@ -52,11 +52,18 @@
data () { data () {
return { return {
theses, theses: [],
emphasized: [] emphasized: []
} }
}, },
apollo: {
theses: {
query: apolloThesesQuery,
update: apolloThesesUpdate
}
},
computed: { computed: {
isEmbedded () { isEmbedded () {
return ( return (

View file

@ -11,17 +11,10 @@
<ul class="party-results"> <ul class="party-results">
<li v-for="party of parties" :key="party.token"> <li v-for="party of parties" :key="party.token">
<router-link :to="{ path: getPartyPath(party.token) }"> <router-link :to="{ path: getPartyPath(party.id) }">
<div class="result-party-info"> <div class="result-party-info">
<div class="result-party-logo"> <div class="result-party-logo">
<img <span>{{ party.token }}</span>
v-if="hasPartyLogo(party.token)"
:src="getPartyLogo(party.token)"
width="50"
height="50"
:alt="party.token"
>
<span v-else>{{ party.token }}</span>
</div> </div>
<h2>{{ getScorePercentage(party.score) }}%</h2> <h2>{{ getScorePercentage(party.score) }}%</h2>
@ -32,31 +25,13 @@
<v-progress <v-progress
class="result-percentage" class="result-percentage"
:value="party.score" :value="party.score"
:max="totalScoredPoints" :max="totalMaxPoints"
/> />
</router-link> </router-link>
<div v-if="party.nationalParty" class="party-results-national"> <div class="party-results-national">
<feather-corner-down-right />
<span> <span>
{{ $t('results.nationalParty') }} {{ party.name }}
<a
class="party-results-national-logo"
:href="party.nationalParty.program"
target="_blank"
rel="noopener"
>
<div v-if="hasPartyLogo(party.nationalParty.token)">
<img
:src="getPartyLogo(party.nationalParty.token)"
:alt="party.nationalParty.name"
:title="party.nationalParty.name"
width="40"
height="40"
>
</div>
<span v-else>{{ party.nationalParty.token }}</span>
</a>
</span> </span>
</div> </div>
</li> </li>
@ -79,36 +54,16 @@
<feather-rotate-cw /> <feather-rotate-cw />
</router-link> </router-link>
</div> </div>
<div class="results-affiliation">
<a
href="https://www.talkingeurope.com/"
target="_blank"
rel="noopener"
>
<img
:src="talkingEuropeBanner"
title="Talking Europe"
alt="Talking Europe Banner"
>
</a>
</div>
</section> </section>
</template> </template>
<script> <script>
import { IPDATA_URL } from '@/config/api' import { getTranslatedUrl } from '@/i18n/helper'
import { getTranslatedUrl, getUserLanguage } from '@/i18n/helper' import { getPartiesWithScores, getTotalMaxPoints } from '@/app/euromat/scoring'
import { import {
MAX_POINTS, apolloPersonsForResultsQuery,
BASE_POINTS, apolloPersonsForResultsUpdate
MIN_POINTS, } from '@/app/euromat/graphqlQueries'
EMPHASIS_POINTS,
getScoringGrid
} from '@/app/euromat/scoring'
import { parties } from '@/data'
const addUp = (a, b) => a + b
export default { export default {
name: 'Results', name: 'Results',
@ -117,20 +72,15 @@
'feather-zoom-in': () => 'feather-zoom-in': () =>
import('vue-feather-icons/icons/ZoomInIcon' /* webpackChunkName: "icons" */), import('vue-feather-icons/icons/ZoomInIcon' /* webpackChunkName: "icons" */),
'feather-rotate-cw': () => 'feather-rotate-cw': () =>
import('vue-feather-icons/icons/RotateCwIcon' /* webpackChunkName: "icons" */), import('vue-feather-icons/icons/RotateCwIcon' /* webpackChunkName: "icons" */)
'feather-corner-down-right': () =>
import('vue-feather-icons/icons/CornerDownRightIcon' /* webpackChunkName: "icons" */)
}, },
data () { data () {
return { return {
userCountry: getUserLanguage().country,
scoringGrid: [], scoringGrid: [],
answers: [],
emphasized: [],
scores: [], scores: [],
parties, parties: [],
totalScoredPoints: 0 totalMaxPoints: 0
} }
}, },
@ -143,14 +93,6 @@
this.$route.query.embedded && this.$route.query.embedded &&
this.$route.query.embedded === 'iframe' this.$route.query.embedded === 'iframe'
) )
},
talkingEuropeBanner () {
try {
return require(`@/assets/talkingeurope/talkingeurope-${this.$i18n.locale}.png`)
} catch (e) {
console.warn('TalkingEurope image not found, defaulting to "en". ', e)
return require(`@/assets/talkingeurope/talkingeurope-en.png`)
}
} }
}, },
@ -167,126 +109,30 @@
} }
if (!emphasized) { if (!emphasized) {
this.$router.push({ path: getTranslatedUrl('theses') }) await this.$router.push({ path: getTranslatedUrl('theses') })
} }
try { const apolloResponse = await this.$apollo.query({ query: apolloPersonsForResultsQuery })
const ipResponse = await fetch(IPDATA_URL) const parties = apolloPersonsForResultsUpdate(apolloResponse.data)
const ipData = await ipResponse.json()
if (ipData.country_code) {
this.userCountry = ipData.country_code.toLowerCase()
}
} catch (error) {
console.warn('Unable to fetch geo location:', error)
}
this.emphasized = emphasized const partiesWithScores = getPartiesWithScores(answers, emphasized, parties)
this.answers = answers this.parties = partiesWithScores.map(party => ({
id: party.id,
this.scoringGrid = getScoringGrid(this.answers, this.emphasized) token: party.token,
this.scores = this.getScorePoints(this.scoringGrid) score: party.score,
this.parties = this.parties name: party.name
.map(this.getScorePerParty) }))
.sort((a, b) => a.score - b.score) .sort((a, b) => a.score - b.score)
.reverse() .reverse()
this.totalScoredPoints = this.scores this.totalMaxPoints = getTotalMaxPoints(answers, emphasized)
.map(s => s.highestScore)
.reduce(addUp, 0)
}, },
methods: { methods: {
getPartyPath (token) { getPartyPath (partyId) {
return `${getTranslatedUrl('party')}/${token.toLowerCase()}` return `${getTranslatedUrl('party')}/${partyId}`
},
getPartyLogo (token) {
try {
return require(`@/assets/svg/${token.toLowerCase().replace(/\s/g, '-')}-logo.svg`)
} catch (e) {
try {
return require(`@/assets/${token.toLowerCase().replace(/\s/g, '-')}-logo.png`)
} catch (error) {
console.warn(`No logo found for party "${token}", falling back to initials.`, error.message)
return false
}
}
},
hasPartyLogo (token) {
try {
require(`@/assets/svg/${token.toLowerCase().replace(/\s/g, '-')}-logo.svg`)
return true
} catch (e) {
try {
require(`@/assets/${token.toLowerCase().replace(/\s/g, '-')}-logo.png`)
return true
} catch (error) {
return false
}
}
}, },
getScorePercentage (score) { getScorePercentage (score) {
return (score / this.totalScoredPoints * 100).toFixed(2) return (score / this.totalMaxPoints * 100).toFixed(2)
},
evalPoints (party, user, emphasis) {
let score = 0
if (user.position === party.position) {
score = MAX_POINTS
} else if (
(user.position === 'positive' && party.position === 'neutral') ||
(user.position === 'neutral' && party.position === 'positive') ||
(user.position === 'negative' && party.position === 'neutral')
) {
score = BASE_POINTS
} else if (
(user.position === 'positive' && party.position === 'negative') ||
(user.position === 'neutral' && party.position === 'negative') ||
(user.position === 'negative' && party.position === 'positive')
) {
score = MIN_POINTS
}
return {
party: party.party,
score: emphasis ? score * EMPHASIS_POINTS : score
}
},
getHighestScore (scores) {
const highestScore = Math.max(...scores.map(s => s.score))
if (!highestScore) {
return MIN_POINTS
}
return highestScore === 1
? MAX_POINTS
: highestScore
},
getScorePoints (grid) {
// 1. Iterate over scoringGrid
// 2. Get user and party positions of each thesis
// 3. Evaluate points based on calculation model for each party
// 4. Count the highest score per thesis
// 5. Return a new object for each thesis row with results
return grid.map(row => {
const partiesFromRow = row.positions.filter(p => p.type === 'party')
const user = row.positions[row.positions.length - 1]
const scores = partiesFromRow.map(party => this.evalPoints(party, user, row.emphasis))
const highestScore = this.getHighestScore(scores)
return {
thesis: row.thesis,
highestScore,
scores
}
})
},
getScorePerParty (party) {
return {
token: party.token,
score: this.scores
.map(t => t.scores.find(s => s.party === party.id).score)
.reduce(addUp, 0),
nationalParty: party['national_parties'][this.userCountry]
}
} }
} }
} }

View file

@ -1,12 +1,12 @@
<template> <template>
<section class="theses"> <section v-if="thesesCount > 0 && theses.length > 0" class="theses">
<div class="header-progress"> <div class="header-progress">
<div> <div>
<span class="progress-current">{{ currentThesisStep }}</span> <span class="progress-current">{{ currentThesisStep }}</span>
<span>/{{ thesesCount }}</span> <span>/{{ thesesCount }}</span>
</div> </div>
<button <button
:disabled="currentThesis === 0" :disabled="currentThesis === 1"
class="btn-dark btn-small" class="btn-dark btn-small"
type="button" type="button"
@click="goBack" @click="goBack"
@ -23,9 +23,9 @@
<div class="theses-controls"> <div class="theses-controls">
<ul class="theses-btns"> <ul class="theses-btns">
<li v-for="option in options" :key="option.label"> <li v-for="possiblePosition in possiblePositions" :key="possiblePosition.label">
<button type="button" @click="submitAnswer(option, $event)"> <button type="button" @click="submitAnswer(possiblePosition, $event)">
{{ option.label }} <component :is="'feather-' + option.icon" /> {{ possiblePosition.label }} <component :is="'feather-' + possiblePosition.icon" />
</button> </button>
</li> </li>
</ul> </ul>
@ -35,17 +35,27 @@
type="button" type="button"
@click="submitAnswer(optionSkip)" @click="submitAnswer(optionSkip)"
> >
{{ optionSkip.label }} <feather-corner-up-right /> {{ optionSkip.label }}
<feather-corner-up-right />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section v-else>
<span>Loading...</span>
</section>
</template> </template>
<script> <script>
import { options, theses } from '@/data' import possiblePositions from '@/app/euromat/possiblePositions'
import { getTranslatedUrl } from '@/i18n/helper' import { getTranslatedUrl } from '@/i18n/helper'
import {
apolloThesesCountQuery,
apolloThesesCountUpdate,
apolloThesesQuery,
apolloThesesUpdate
} from '@/app/euromat/graphqlQueries'
export default { export default {
name: 'EuroMat', name: 'EuroMat',
@ -63,12 +73,24 @@
data () { data () {
return { return {
currentThesis: 0, currentThesis: 1,
thesesCount: theses.length, theses: [],
thesesCount: 0,
answers: [] answers: []
} }
}, },
apollo: {
theses: {
query: apolloThesesQuery,
update: apolloThesesUpdate
},
thesesCount: {
query: apolloThesesCountQuery,
update: apolloThesesCountUpdate
}
},
computed: { computed: {
isEmbedded () { isEmbedded () {
return ( return (
@ -77,22 +99,22 @@
) )
}, },
currentThesisStep () { currentThesisStep () {
return this.currentThesis + 1 return this.currentThesis
}, },
thesisTitle () { thesisTitle () {
if (this.currentThesis === this.thesesCount) { if (this.currentThesis > this.thesesCount) {
return return
} }
return this.getThesis(this.currentThesis).thesis[this.$i18n.locale] return this.getThesis(this.currentThesis).thesis[this.$i18n.locale]
}, },
thesisCategory () { thesisCategory () {
if (this.currentThesis === this.thesesCount) { if (this.currentThesis > this.thesesCount) {
return return
} }
return this.getThesis(this.currentThesis).category[this.$i18n.locale] return this.getThesis(this.currentThesis).category[this.$i18n.locale]
}, },
options () { possiblePositions () {
return options.map(option => return possiblePositions.map(option =>
Object.assign({}, option, { Object.assign({}, option, {
label: this.$t(`theses.${option.position}`), label: this.$t(`theses.${option.position}`),
icon: this.getIconName(option.position) icon: this.getIconName(option.position)
@ -100,7 +122,7 @@
.filter(option => option.position !== 'skipped') .filter(option => option.position !== 'skipped')
}, },
optionSkip () { optionSkip () {
const skipped = options.find(option => option.position === 'skipped') const skipped = possiblePositions.find(option => option.position === 'skipped')
return Object.assign({}, skipped, { return Object.assign({}, skipped, {
label: this.$t('theses.skipped') label: this.$t('theses.skipped')
}) })
@ -118,7 +140,7 @@
} }
}, },
getThesis (id) { getThesis (id) {
return theses.find(t => t.id === id) return this.theses.find(t => t.id === id)
}, },
goBack () { goBack () {
const thesis = this.getThesis(this.currentThesis) const thesis = this.getThesis(this.currentThesis)
@ -138,7 +160,7 @@
event && event.target.blur() event && event.target.blur()
window.scrollTo(0, 0) window.scrollTo(0, 0)
if (this.currentThesis === this.thesesCount) { if (this.currentThesis > this.thesesCount) {
this.forwardToResults() this.forwardToResults()
} }
}, },

View file

@ -0,0 +1,115 @@
import gql from 'graphql-tag'
import possiblePositions from '@/app/euromat/possiblePositions'
export function getPositionById (id) {
return possiblePositions.find(option => option.id === id).position
}
export const apolloThesesQuery = gql`{
allQuestions(orderBy: ID_ASC) {
nodes {
category: categoryByCategoryId {
nodeId
title
}
text
id
nodeId
}
}
}`
export const apolloThesesUpdate = data => data.allQuestions.nodes.map(node => ({
id: node.id,
thesis: {
de: node.text
},
category: {
de: node.category.title
}
}))
export const apolloThesesCountQuery = gql`{
allQuestions {
totalCount
}
}`
export const apolloThesesCountUpdate = data => data.allQuestions.totalCount
export const apolloPersonsForResultsQuery = gql`{
allPeople(condition: {role: CANDYMAT_CANDIDATE}) {
nodes {
nodeId
firstName
lastName
id
answersByPersonId {
nodes {
nodeId
position
questionId
text
}
}
}
}
}`
export const apolloPersonsForResultsUpdate = data => data.allPeople.nodes.map(person => ({
id: person.id,
name: `${person.firstName} ${person.lastName}`,
token: person.firstName.charAt(0) + person.lastName.charAt(0),
positions: person.answersByPersonId.nodes.map(answer => ({
thesis: answer.questionId,
position: getPositionById(answer.position),
statement: {
de: answer.text
}
}))
}))
export const apolloPersonPositionsQuery = gql`
query Person($partyId: Int!) {
personById(id: $partyId) {
nodeId
id
firstName
lastName
answersByPersonId {
nodes {
nodeId
position
personId
text
questionByQuestionId {
nodeId
categoryByCategoryId {
nodeId
title
}
text
id
description
}
}
}
}
}`
export const apolloPersonPositionsUpdate = data => ({
id: data.personById.id,
name: `${data.personById.firstName} ${data.personById.lastName}`,
token: data.personById.firstName.charAt(0) + data.personById.lastName.charAt(0),
theses: data.personById.answersByPersonId.nodes.map(answer => {
const question = answer.questionByQuestionId
return {
id: question.id,
thesis: question.text,
category: question.categoryByCategoryId.title,
position: getPositionById(answer.position),
statement: answer.text,
showStatement: false
}
})
})

View file

@ -1,4 +1,4 @@
const options = [ const possiblePositions = [
{ {
'position': 'positive', 'position': 'positive',
'id': 0 'id': 0
@ -16,5 +16,4 @@ const options = [
'id': 3 'id': 3
} }
] ]
export default possiblePositions
export default options

View file

@ -1,21 +1,60 @@
import { parties } from '@/data'
export const MAX_POINTS = 2 export const MAX_POINTS = 2
export const BASE_POINTS = 1 export const BASE_POINTS = 1
export const MIN_POINTS = 0 export const MIN_POINTS = 0
export const EMPHASIS_POINTS = 2 export const EMPHASIS_POINTS = 2
export function getPartyPositions (thesis) { export function getPartiesWithScores (answers, emphasized, partiesPositions) {
return parties.map(party => { const scorePointsGrid = getScorePointsGrid(answers, emphasized, partiesPositions)
const position = party.positions.find(p => p.thesis === thesis)
return partiesPositions.map(party => ({
...party,
score: getTotalScorePerParty(party, scorePointsGrid)
}))
}
export function getTotalMaxPoints (userAnswers, userEmphasized) {
return userAnswers.map(answer => {
const emphasis = userEmphasized.filter(e => e.thesis === answer.thesis).length >= 1
return getMaxScorePerThesis(answer.position, emphasis)
}).reduce((total, maxScorePerRow) => total + maxScorePerRow)
}
function getScorePointsGrid (userAnswers, userEmphasized, partiesPositions) {
// 1. Iterate over scoringGrid
// 2. Get user and party positions of each thesis
// 3. Evaluate points based on calculation model for each party
// 4. Get the maximum score per thesis
// 5. Return a new object for each thesis row with results
const scoringGrid = getScoringGrid(userAnswers, userEmphasized, partiesPositions)
return scoringGrid.map(row => {
const partiesFromRow = row.positions.filter(p => p.type === 'party')
const userPosition = getUserPosition(row)
const scores = partiesFromRow.map(party => ({
party: party.party,
score: evalPointsPerThesisPerParty(party.position, userPosition, row.emphasis)
}))
return { return {
type: 'party', thesis: row.thesis,
party: party.id, scores
position: (position && position.position) || {}
} }
}) })
} }
function getTotalScorePerParty (party, scorePointsGrid) {
return scorePointsGrid
.map(thesis => thesis.scores.find(scores => scores.party === party.id).score)
.reduce((total, score) => total + score, 0)
}
function getMaxScorePerThesis (userPosition, emphasis) {
return userPosition === 'skipped' ? MIN_POINTS : emphasis ? MAX_POINTS * EMPHASIS_POINTS : MAX_POINTS
}
function getUserPosition (row) {
return row.positions.find(p => p.type === 'user').position
}
// Grid example: // Grid example:
// [ // [
// { // {
@ -33,15 +72,48 @@ export function getPartyPositions (thesis) {
// }, // },
// ... // ...
// ] // ]
export function getScoringGrid (userAnswers, emphasizedTheses) { function getScoringGrid (userAnswers, emphasizedTheses, parties) {
return userAnswers.map(answer => ( return userAnswers.map(answer => (
{ {
thesis: answer.thesis, thesis: answer.thesis,
emphasis: emphasizedTheses.filter(e => e.thesis === answer.thesis).length >= 1, emphasis: emphasizedTheses.filter(e => e.thesis === answer.thesis).length >= 1,
positions: [ positions: [
...getPartyPositions(answer.thesis), ...getPartyPositions(answer.thesis, parties),
...[{ type: 'user', position: answer.position }] ...[{ type: 'user', position: answer.position }]
] ]
} }
)) ))
} }
function getPartyPositions (thesis, parties) {
return parties.map(party => {
const position = party.positions.find(p => p.thesis === thesis)
return {
type: 'party',
party: party.id,
position: (position && position.position) || {}
}
})
}
export function evalPointsPerThesisPerParty (partyPosition, userPosition, emphasis) {
let score = 0
if (userPosition === partyPosition) {
score = MAX_POINTS
} else if (
(userPosition === 'positive' && partyPosition === 'neutral') ||
(userPosition === 'neutral' && partyPosition === 'positive') ||
(userPosition === 'neutral' && partyPosition === 'negative') ||
(userPosition === 'negative' && partyPosition === 'neutral')
) {
score = BASE_POINTS
} else if (
(userPosition === 'positive' && partyPosition === 'negative') ||
(userPosition === 'negative' && partyPosition === 'positive')
) {
score = MIN_POINTS
}
return emphasis ? score * EMPHASIS_POINTS : score
}

View file

@ -0,0 +1,109 @@
import { evalPointsPerThesisPerParty, getPartiesWithScores, getTotalMaxPoints } from '@/app/euromat/scoring'
const positionDef = ['positive', 'neutral', 'negative', 'skipped']
// See offficial Rechenmodell of bpb from 2019
const rechenmodellGrid = [
[0, 0, 0, 0, 0, false],
[0, 0, 2, 0, 1, false],
[0, 2, 2, 0, 0, false],
[0, 0, 0, 0, 2, false],
[1, 1, 0, 2, 2, true],
[0, 0, 2, 0, 0, false],
[0, 2, 2, 1, 0, false],
[0, 0, 0, 2, 2, false],
[2, 0, 2, 2, 1, false],
[1, 2, 2, 2, 1, false],
[2, 2, 0, 0, 2, false],
[0, 2, 2, 2, 2, false],
[0, 2, 0, 0, 3, false],
[0, 2, 1, 2, 2, false],
[2, 0, 0, 1, 2, false],
[2, 0, 0, 0, 2, true],
[2, 0, 0, 2, 3, false],
[0, 0, 0, 0, 2, false],
[2, 0, 0, 0, 1, false],
[2, 0, 0, 0, 1, false],
[0, 0, 2, 2, 2, true],
[2, 2, 1, 2, 2, false],
[2, 2, 0, 2, 2, false],
[2, 2, 2, 0, 2, false],
[0, 2, 0, 2, 1, false],
[1, 2, 2, 0, 0, false],
[2, 0, 2, 0, 0, false],
[2, 0, 0, 2, 2, false],
[0, 2, 0, 2, 1, false],
[1, 2, 0, 2, 2, false],
[0, 1, 2, 0, 0, false],
[1, 2, 2, 0, 1, false],
[2, 0, 2, 2, 1, false],
[0, 2, 2, 2, 2, false],
[0, 2, 0, 0, 0, false],
[0, 2, 0, 0, 1, false],
[2, 2, 2, 2, 0, false],
[2, 0, 0, 2, 0, false]
]
const rechenmodellExpectedTotalScorePerParty = [44, 37, 28, 50]
const rechenmodellExpectedMaxScore = 78
const testParties = [1, 2, 3, 4].map(partyId => ({
id: partyId,
token: 'idontcare',
positions: rechenmodellGrid.map((thesis, index) => ({
thesis: index,
position: positionDef[thesis[partyId - 1]]
}))
}))
const testAnswers = rechenmodellGrid.map((thesis, index) => ({
position: positionDef[thesis[4]],
thesis: index
}))
const testEmphasis = rechenmodellGrid
.map((thesis, index) => ({
...thesis,
thesis: index
}))
.filter((thesis, index) => thesis[5])
.map(thesis => ({ thesis: thesis.thesis }))
describe('The getPartiesWithScores function', () => {
it('returns the correct total scores according to the Rechenmodell example of bpb', () => {
const resultPartiesWithScores = getPartiesWithScores(testAnswers, testEmphasis, testParties)
expect(resultPartiesWithScores.map(party => party.score)).toEqual(rechenmodellExpectedTotalScorePerParty)
})
})
describe('The getTotalMaxPoints function', () => {
it('returns the correct maximum score points according to the Rechenmodell example of bpb', () => {
const resultTotalMaxPoints = getTotalMaxPoints(testAnswers, testEmphasis)
expect(resultTotalMaxPoints).toEqual(rechenmodellExpectedMaxScore)
})
})
describe('The evalPointsPerThesisPerParty fucntion', () => {
test.each`
partyPosition | userPosition | expectedScore | expectedScoreWithEmphasis
${'negative'} | ${'negative'} | ${2} | ${4}
${'negative'} | ${'neutral'} | ${1} | ${2}
${'negative'} | ${'positive'} | ${0} | ${0}
${'neutral'} | ${'negative'} | ${1} | ${2}
${'neutral'} | ${'neutral'} | ${2} | ${4}
${'neutral'} | ${'positive'} | ${1} | ${2}
${'positive'} | ${'negative'} | ${0} | ${0}
${'positive'} | ${'neutral'} | ${1} | ${2}
${'positive'} | ${'positive'} | ${2} | ${4}
${'positive'} | ${'skipped'} | ${0} | ${0}
`('returns the correct score (according to the Rechenmodell of the bpb' +
' if the party position is $partyPosition and the user\'s position is $userPosition',
({ partyPosition, userPosition, expectedScore, expectedScoreWithEmphasis }) => {
const resultScore = evalPointsPerThesisPerParty(partyPosition, userPosition, false)
const resultScoreWithEmphasis = evalPointsPerThesisPerParty(partyPosition, userPosition, true)
expect(resultScore).toEqual(expectedScore)
expect(resultScoreWithEmphasis).toEqual(expectedScoreWithEmphasis)
})
})

View file

@ -1,28 +1,21 @@
<template> <template>
<section> <section>
<header :class="['party-header', { 'no-party-link': !partyProgramLink }]"> <header :class="['party-header', { 'no-party-link': !partyProgramLink }]">
<div :class="['party-header-logo', { 'no-logo': !hasPartyLogo }]"> <div :class="['party-header-logo', 'no-logo']">
<img <span>
v-if="hasPartyLogo" {{ party.token }}
:src="partyLogo"
:width="logoSize"
:height="logoSize"
:alt="partyName"
>
<span v-else>
{{ partyToken }}
</span> </span>
</div> </div>
<div class="party-header-info"> <div class="party-header-info">
<router-link v-if="!!answers" <router-link v-if="!!answers"
class="btn btn-dark btn-small" class="btn btn-dark btn-small"
:to="{ path: resultsPath }" :to="{ path: resultsPath }"
> >
{{ $t('party.backButtonLabel') }} {{ $t('party.backButtonLabel') }}
<feather-corner-up-left /> <feather-corner-up-left />
</router-link> </router-link>
<h1>{{ partyName }}</h1> <h1>{{ party.name }}</h1>
<a <a
v-if="!!partyProgramLink" v-if="!!partyProgramLink"
class="btn" class="btn"
@ -38,9 +31,9 @@
<div class="theses-legend"> <div class="theses-legend">
<p>{{ $t('party.legendLabel') }}:</p> <p>{{ $t('party.legendLabel') }}:</p>
<ul> <ul>
<li v-for="option in options" :key="option.position"> <li v-for="possiblePosition in possiblePositions" :key="possiblePosition.position">
<component :is="'feather-' + positionToIconName(option.position)" /> <component :is="'feather-' + positionToIconName(possiblePosition.position)" />
<span>{{ $t(`theses.${option.position}`) }}</span> <span>{{ $t(`theses.${possiblePosition.position}`) }}</span>
</li> </li>
</ul> </ul>
</div> </div>
@ -54,36 +47,36 @@
</h2> </h2>
</li> </li>
<li v-for="thesis in theses" :key="thesis.id"> <li v-for="thesis in party.theses" :key="thesis.id">
<div class="thesis-facts"> <div class="thesis-facts">
<div class="list-thesis" @click="toggleStatement(thesis.id)"> <div class="list-thesis" @click="toggleStatement(thesis)">
<div class="thesis-subline"> <div class="thesis-subline">
<component :is="'feather-' + chevronIcon(thesis.id)" /> <component :is="'feather-' + chevronIcon(thesis.showStatement)" />
<span>{{ getCategory(thesis.category) }}</span> <span>{{ thesis.category }}</span>
</div> </div>
<h3>{{ getThesis(thesis.thesis) }}</h3> <h3>{{ thesis.thesis }}</h3>
</div> </div>
<div class="statements-party"> <div class="statements-party">
<component :is="'feather-' + getPartyPosition(thesis.id)" /> <component :is="'feather-' + positionToIconName(thesis.position)" />
</div> </div>
<div v-if="!!answers" class="statements-user"> <div v-if="!!answers" class="statements-user">
<component :is="'feather-' + getUserPosition(thesis.id)" /> <component :is="'feather-' + getUserPosition(thesis.id)" />
</div> </div>
</div> </div>
<div v-show="showStatement(thesis.id)" class="thesis-statement"> <div v-show="thesis.showStatement" class="thesis-statement">
<p><strong>{{ $t('party.partyAnswer') }}:</strong></p> <p><strong>{{ $t('party.partyAnswer') }}:</strong></p>
<blockquote> <blockquote>
{{ getPartyStatement(thesis.id) }} {{ thesis.statement }}
</blockquote> </blockquote>
</div> </div>
</li> </li>
</ul> </ul>
<router-link v-if="!!answers" <router-link v-if="!!answers"
class="btn btn-dark btn-small" class="btn btn-dark btn-small"
:to="{ path: resultsPath }" :to="{ path: resultsPath }"
> >
{{ $t('party.backButtonLabel') }} {{ $t('party.backButtonLabel') }}
<feather-corner-up-left /> <feather-corner-up-left />
@ -92,8 +85,10 @@
</template> </template>
<script> <script>
import { parties, theses, options } from '@/data' import possiblePositions from '@/app/euromat/possiblePositions'
import { getTranslatedUrl } from '@/i18n/helper' import { getTranslatedUrl } from '@/i18n/helper'
import { apolloPersonPositionsQuery, apolloPersonPositionsUpdate } from '@/app/euromat/graphqlQueries'
export default { export default {
name: 'Party', name: 'Party',
@ -127,13 +122,27 @@
return { return {
logoSize: 60, logoSize: 60,
partyLogo: this.hasPartyLogo && require(`@/assets/svg/${this.$route.params.token}-logo.svg`), partyId: this.$route.params.token,
partyToken: this.$route.params.token.toUpperCase(), party: {
party: parties.find(p => p.token === this.$route.params.token.toUpperCase()), id: this.$route.params.token,
token: this.$route.params.token,
name: 'Loading ...',
theses: []
},
answers, answers,
toggles: theses.map(t => ({ id: t.id, show: false })), possiblePositions
theses, }
options },
apollo: {
party: {
query: apolloPersonPositionsQuery,
variables () {
return {
partyId: parseInt(this.$route.params.token)
}
},
update: apolloPersonPositionsUpdate
} }
}, },
@ -141,34 +150,17 @@
resultsPath () { resultsPath () {
return getTranslatedUrl('results', getTranslatedUrl('theses', null, true)) return getTranslatedUrl('results', getTranslatedUrl('theses', null, true))
}, },
hasPartyLogo () {
try {
require(`@/assets/svg/${this.$route.params.token}-logo.svg`)
return true
} catch (error) {
return false
}
},
partyName () {
return this.party.name[this.$i18n.locale]
},
partyProgramLink () { partyProgramLink () {
return this.party.program[this.$i18n.locale] return false
} }
}, },
methods: { methods: {
chevronIcon (id) { chevronIcon (showStatement) {
return this.showStatement(id) return showStatement
? 'chevron-up' ? 'chevron-up'
: 'chevron-down' : 'chevron-down'
}, },
getCategory (category) {
return category[this.$i18n.locale]
},
getThesis (thesis) {
return thesis[this.$i18n.locale]
},
positionToIconName (position) { positionToIconName (position) {
switch (position) { switch (position) {
case 'positive': case 'positive':
@ -182,27 +174,13 @@
return 'circle' return 'circle'
} }
}, },
getPartyPosition (id) {
return this.positionToIconName(
this.party.positions.find(p => p.thesis === id).position
)
},
getPartyStatement (id) {
return this.party.positions
.find(p => p.thesis === id)
.statement[this.$i18n.locale]
},
getUserPosition (id) { getUserPosition (id) {
return this.positionToIconName( return this.positionToIconName(
this.answers.find(a => a.thesis === id).position this.answers.find(a => a.thesis === id).position
) )
}, },
showStatement (id) { toggleStatement (thesis) {
return this.toggles.find(t => t.id === id).show thesis.showStatement = !thesis.showStatement
},
toggleStatement (id) {
const statement = this.toggles.find(t => t.id === id)
statement.show = !statement.show
} }
} }
} }

View file

@ -1 +0,0 @@
export const IPDATA_URL = 'https://api.ipdata.co/?api-key=test'

View file

@ -1,7 +1,4 @@
import { loadContent } from '@/helper/content' import { loadContent } from '@/helper/content'
import options from './options'
import theses from './theses'
import parties from './parties'
const i18n = loadContent( const i18n = loadContent(
'meta', 'meta',
@ -9,8 +6,5 @@ const i18n = loadContent(
) )
export { export {
options,
theses,
parties,
i18n i18n
} }

File diff suppressed because it is too large Load diff

View file

@ -1,45 +0,0 @@
const theses = [
{
'id': 0,
'category': {
'de': 'Politische Bildung',
'en': 'Civic Education',
'fr': 'Education civique',
'dk': 'Medborgerskab',
'si': 'Državljanska vzgoja',
'cz': 'Občanské vzdělávání',
'pl': 'Edukacja obywatelska'
},
'thesis': {
'de': 'Europapolitische Bildung sollte Teil der Lehrpläne aller Mitgliedsländer sein.',
'en': 'European civic education should be part of the school curricula of all member countries.',
'fr': 'Léducation civique européenne devrait faire partie des programmes scolaires de tous les Etats-membres.',
'dk': 'Europæisk medborgerskab skal være en del af skolepensum i alle medlemslande.',
'si': 'Evropska državljanska vzgoja bi morala biti del šolskega kurikuluma v vseh državah članicah.',
'cz': 'Evropské občanské vzdělávání by mělo být součástí školních osnov ve všech členských státech.',
'pl': 'Europejska edukacja obywatelska powinna być częścią programów nauczania we wszystkich państwach członkowskich.'
}
},
{
'id': 1,
'category': {
'de': 'Europäische Armee',
'en': 'European Army',
'fr': 'Forces armées',
'dk': 'Fælles forsvar',
'si': 'Evropska vojska',
'cz': 'Evropská armáda',
'pl': 'Armia europejska'
},
'thesis': {
'de': 'Langfristig sollten die EU-Mitgliedstaaten ihre Streitkräfte zu einer europäischen Armee zusammenschließen.',
'en': 'In the long-term EU member states should merge their armed forces to a European army.',
'fr': "A terme, les forces armées des pays membres de l'UE devraient fusionner.",
'dk': 'På lang sigt skal EU-medlemslandenes forsvar samles i en fælles europæisk hær.',
'si': 'Države članice EU bi morale na dolgi rok združiti svoje oborožene sile v skupno evropsko vojsko.',
'cz': 'V dlouhodobém horizontu by měly být armády členských států sloučeny do Evropské armády.',
'pl': 'W perspektywie długoterminowej państwa członkowskie UE powinny połączyć swoje siły zbrojne w armię europejską.'
}
}
]
export default theses

View file

@ -8,14 +8,26 @@ import storage from '@/helper/storage'
import '@/registerComponents' import '@/registerComponents'
import '@/registerServiceWorker' import '@/registerServiceWorker'
import VueApollo from 'vue-apollo'
import ApolloClient from 'apollo-boost'
Vue.config.productionTip = false Vue.config.productionTip = false
Vue.use(VueSVGIcon) Vue.use(VueSVGIcon)
Vue.use(storage) Vue.use(storage)
Vue.use(VueApollo)
const apolloClient = new ApolloClient({
uri: 'http://localhost:5433/graphql'
})
const apolloProvider = new VueApollo({
defaultClient: apolloClient
})
new Vue({ new Vue({
i18n, i18n,
router, router,
apolloProvider,
data: { data: {
backupStorage: { backupStorage: {
answers: undefined, answers: undefined,