Last not Least: Get party details page from backend

Also
* reorder graphQl queries: Always a query constant + update function
* use id instead of token (initials) to identify person in url
* Remove any party logo related sections since currently not needed
* Move "options" from data closer to actual code since it doesn't really belong to data
  (data may be deleted in the future, anyway)
* add nodeId in gql queries wherever possible to avoid problems with the apollo cache
* add note about graphql schema import to README.md
This commit is contained in:
Christoph Lienhard 2020-08-22 16:40:12 +02:00
parent 5512955af4
commit 64e6b861b6
11 changed files with 196 additions and 4035 deletions

2
.gitignore vendored
View file

@ -25,4 +25,4 @@ yarn-error.log*
*.sw* *.sw*
# GraphQl plugin related # GraphQl plugin related
./postgraphile-schema.graphql postgraphile-schema.graphql

View file

@ -16,10 +16,19 @@ 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 ### Notes
* To keep the diff to the original euromat source as small as possible certain variables follow a naming convention * 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. which may seem weird at first.

View file

@ -38,7 +38,7 @@
<script> <script>
import { getTranslatedUrl } from '@/i18n/helper' import { getTranslatedUrl } from '@/i18n/helper'
import { apolloTheses } from '@/app/euromat/graphqlQueries' import { apolloThesesQuery, apolloThesesUpdate } from '@/app/euromat/graphqlQueries'
export default { export default {
name: 'Emphasis', name: 'Emphasis',
@ -58,7 +58,10 @@
}, },
apollo: { apollo: {
theses: apolloTheses theses: {
query: apolloThesesQuery,
update: apolloThesesUpdate
}
}, },
computed: { computed: {

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>
@ -67,7 +60,10 @@
<script> <script>
import { getTranslatedUrl } from '@/i18n/helper' import { getTranslatedUrl } from '@/i18n/helper'
import { getPartiesWithScores, getTotalMaxPoints } from '@/app/euromat/scoring' import { getPartiesWithScores, getTotalMaxPoints } from '@/app/euromat/scoring'
import { apolloPersonsForResults, parseApolloPersonForResults } from '@/app/euromat/graphqlQueries' import {
apolloPersonsForResultsQuery,
apolloPersonsForResultsUpdate
} from '@/app/euromat/graphqlQueries'
export default { export default {
name: 'Results', name: 'Results',
@ -116,11 +112,12 @@
await this.$router.push({ path: getTranslatedUrl('theses') }) await this.$router.push({ path: getTranslatedUrl('theses') })
} }
const apolloResponse = await this.$apollo.query(apolloPersonsForResults) const apolloResponse = await this.$apollo.query({ query: apolloPersonsForResultsQuery })
const parties = parseApolloPersonForResults(apolloResponse.data) const parties = apolloPersonsForResultsUpdate(apolloResponse.data)
const partiesWithScores = getPartiesWithScores(answers, emphasized, parties) const partiesWithScores = getPartiesWithScores(answers, emphasized, parties)
this.parties = partiesWithScores.map(party => ({ this.parties = partiesWithScores.map(party => ({
id: party.id,
token: party.token, token: party.token,
score: party.score, score: party.score,
name: party.name name: party.name
@ -131,33 +128,8 @@
}, },
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.totalMaxPoints * 100).toFixed(2) return (score / this.totalMaxPoints * 100).toFixed(2)

View file

@ -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>
@ -48,9 +48,14 @@
</template> </template>
<script> <script>
import { options } from '@/data' import possiblePositions from '@/app/euromat/possiblePositions'
import { getTranslatedUrl } from '@/i18n/helper' import { getTranslatedUrl } from '@/i18n/helper'
import { apolloTheses, apolloThesesCount } from '@/app/euromat/graphqlQueries' import {
apolloThesesCountQuery,
apolloThesesCountUpdate,
apolloThesesQuery,
apolloThesesUpdate
} from '@/app/euromat/graphqlQueries'
export default { export default {
name: 'EuroMat', name: 'EuroMat',
@ -76,8 +81,14 @@
}, },
apollo: { apollo: {
theses: apolloTheses, theses: {
thesesCount: apolloThesesCount query: apolloThesesQuery,
update: apolloThesesUpdate
},
thesesCount: {
query: apolloThesesCountQuery,
update: apolloThesesCountUpdate
}
}, },
computed: { computed: {
@ -102,8 +113,8 @@
} }
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)
@ -111,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')
}) })

View file

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