Clicking on a report now shows more infos and let users dismiss it

You should update your database by running
```
ALTER TABLE report ADD COLUMN (upvotes INTEGER NOT NULL, downvotes INTEGER NOT NULL)
```

Fix issue #8.
This commit is contained in:
Lucas Verney 2018-07-05 22:40:24 +02:00
parent dde886d46e
commit 9d4842b44c
19 changed files with 314 additions and 14 deletions

View File

@ -16,6 +16,7 @@
"leaflet": "^1.3.1",
"leaflet-tracksymbol": "^1.0.8",
"material-icons": "^0.2.3",
"moment": "^2.22.2",
"nosleep.js": "^0.7.0",
"roboto-fontface": "^0.9.0",
"vue": "^2.5.2",

View File

@ -44,6 +44,8 @@ class Report(BaseModel):
default=lambda: arrow.utcnow().replace(microsecond=0).datetime
)
is_open = peewee.BooleanField(default=True)
upvotes = peewee.IntegerField(default=0)
downvotes = peewee.IntegerField(default=0)
def to_json(self):
return {

View File

@ -104,3 +104,51 @@ def post_report():
return {
"data": r.to_json()
}
@bottle.route('/api/v1/reports/:id/upvote', ["POST", "OPTIONS"])
def upvote_report(id):
"""
API v1 POST upvote route.
Example::
POST /api/v1/reports/1/upvote
"""
# Handle CORS
if bottle.request.method == 'OPTIONS':
return {}
r = Report.get(Report.id == id)
if not r:
return jsonapi.JsonApiError(404, "Invalid report id.")
r.upvotes += 1
r.save()
return {
"data": r.to_json()
}
@bottle.route('/api/v1/reports/:id/downvote', ["POST", "OPTIONS"])
def downvote_report(id):
"""
API v1 POST downvote route.
Example::
POST /api/v1/reports/1/downvote
"""
# Handle CORS
if bottle.request.method == 'OPTIONS':
return {}
r = Report.get(Report.id == id)
if not r:
return jsonapi.JsonApiError(404, "Invalid report id.")
r.downvotes += 1
r.save()
return {
"data": r.to_json()
}

View File

@ -30,3 +30,27 @@ export function getReports() {
throw exc;
});
}
export function downvote(id) {
return fetch(`${BASE_URL}api/v1/reports/${id}/downvote`, {
method: 'POST',
})
.then(response => response.json())
.then(response => response.data)
.catch((exc) => {
console.error(`Unable to downvote report: ${exc}.`);
throw exc;
});
}
export function upvote(id) {
return fetch(`${BASE_URL}api/v1/reports/${id}/upvote`, {
method: 'POST',
})
.then(response => response.json())
.then(response => response.data)
.catch((exc) => {
console.error(`Unable to upvote report: ${exc}.`);
throw exc;
});
}

View File

@ -0,0 +1,129 @@
<template>
<v-flex xs12 v-if="report" class="reportCard">
<div></div>
<v-container grid-list-md fluid>
<v-layout row align-center>
<v-flex xs3 md2>
<img :src="report.icon" :alt="report.label" class="icon">
</v-flex>
<v-flex xs5 md6>
<v-layout row wrap>
<v-flex xs12 class="firstLine subheading font-weight-medium">
{{ report.label }}
</v-flex>
<v-flex xs12 class="secondLine">
{{ $t('reportCard.Reported') }} {{ report.fromNow }}
</v-flex>
</v-layout>
</v-flex>
<v-flex xs2 class="text-xs-center">
<v-btn color="green" dark small icon class="smallButton" @click.stop="upvote">
<v-icon>thumb_up</v-icon>
</v-btn>
</v-flex>
<v-flex xs2 class="text-xs-center">
<v-btn color="red" dark medium icon class="mediumButton" @click.stop="downvote">
<v-icon>thumb_down</v-icon>
</v-btn>
</v-flex>
<v-btn
color="grey"
class="lighten-1"
dark
small
absolute
bottom
left
fab
@click.stop="closeReportCard"
>
<v-icon>close</v-icon>
</v-btn>
</v-layout>
</v-container>
</v-flex>
</template>
<script>
import moment from 'moment';
import { REPORT_TYPES } from '@/constants';
export default {
computed: {
report() {
const reportID = this.$store.state.reportDetailsID;
if (reportID != null) {
const report = this.$store.state.reports.find(item => item.id === reportID);
return {
fromNow: moment(report.attributes.datetime).fromNow(),
icon: this.icons[report.attributes.type],
id: report.id,
label: this.$t(`reportLabels.${report.attributes.type}`),
type: report.attributes.type,
};
}
return null;
},
},
data() {
const icons = {};
Object.keys(REPORT_TYPES).forEach((type) => {
icons[type] = REPORT_TYPES[type].image;
});
return {
icons,
};
},
methods: {
closeReportCard() {
return this.$store.dispatch('showReportDetails', null);
},
downvote() {
const reportID = this.report.id;
this.closeReportCard(); // Resets this.report
return this.$store.dispatch('downvote', reportID);
},
upvote() {
const reportID = this.report.id;
this.closeReportCard(); // Resets this.report
return this.$store.dispatch('upvote', reportID);
},
},
};
</script>
<style scoped>
.reportCard {
position: absolute;
z-index: 1001;
width: 100%;
background-color: white;
}
.icon {
max-height: 52px;
max-width: 100%;
}
.mediumButton {
height: 56px;
width: 56px;
}
.smallButton {
height: 40px;
width: 40px;
}
.secondLine {
padding-top: 1px;
padding-bottom: 1em !important;
}
.firstLine {
padding: 2px !important;
}
</style>

View File

@ -11,7 +11,7 @@
<v-btn
color="red darken-1"
@click="error = false"
@click="error = null"
dark
large
>
@ -60,7 +60,7 @@ export default {
},
data() {
return {
error: false,
error: null,
REPORT_TYPES,
};
},
@ -71,8 +71,9 @@ export default {
type,
lat: this.lat,
lng: this.lng,
}).catch(() => {
this.error = true;
}).catch((exc) => {
console.error(exc);
this.error = exc;
});
},
},

View File

@ -1,5 +1,5 @@
<template>
<v-lmarker :lat-lng="marker.latLng" :icon="icons[marker.type]"></v-lmarker>
<v-lmarker :lat-lng="marker.latLng" :icon="icon" @click="onClick"></v-lmarker>
</template>
<script>
@ -9,14 +9,23 @@ export default {
props: {
marker: Object,
},
computed: {
icon() {
if (this.$store.state.reportDetailsID === this.marker.id) {
return REPORT_TYPES[this.marker.type].markerLarge;
}
return REPORT_TYPES[this.marker.type].marker;
},
},
data() {
const icons = {};
Object.keys(REPORT_TYPES).forEach((type) => {
icons[type] = REPORT_TYPES[type].marker;
});
return {
icons,
showCard: false,
};
},
methods: {
onClick() {
this.$store.dispatch('showReportDetails', this.marker.id);
},
},
};
</script>

View File

@ -18,6 +18,12 @@ export const REPORT_TYPES = {
iconSize: [40, 40],
iconAnchor: [20, 40],
}),
markerLarge: L.icon({
iconUrl: gcumMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
}),
},
interrupt: {
label: 'reportLabels.interrupt',
@ -27,6 +33,11 @@ export const REPORT_TYPES = {
iconSize: [40, 40],
iconAnchor: [20, 40],
}),
markerLarge: L.icon({
iconUrl: interruptMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
}),
},
obstacle: {
label: 'reportLabels.obstacle',
@ -36,6 +47,11 @@ export const REPORT_TYPES = {
iconSize: [40, 40],
iconAnchor: [20, 40],
}),
markerLarge: L.icon({
iconUrl: obstacleMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
}),
},
pothole: {
label: 'reportLabels.pothole',
@ -45,6 +61,11 @@ export const REPORT_TYPES = {
iconSize: [40, 40],
iconAnchor: [20, 40],
}),
markerLarge: L.icon({
iconUrl: potholeMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
}),
},
};
@ -53,6 +74,9 @@ export const MOCK_LOCATION_UPDATE_INTERVAL = 10 * 1000;
export const UPDATE_REPORTS_DISTANCE_THRESHOLD = 500;
// Minimal ratio between upvotes and downvotes needed for a report to be shown
export const REPORT_VOTES_THRESHOLD = 0.5;
export const EARTH_RADIUS = 6378137;
export const DEFAULT_ZOOM = 17;

View File

@ -25,6 +25,9 @@ export default {
retry: 'Retry',
spaceBeforeDoublePunctuations: '',
},
reportCard: {
Reported: 'Reported',
},
reportDialog: {
unableToSendDescription: 'There was a network issue preventing from sending the latest report.',
unableToSendTitle: 'Unable to send latest report',

View File

@ -25,6 +25,9 @@ export default {
retry: 'Réessayer',
spaceBeforeDoublePunctuations: ' ',
},
reportCard: {
Reported: 'Signalé',
},
reportDialog: {
unableToSendDescription: "Une erreur de réseau empêche l'envoi du dernier signalement.",
unableToSendTitle: "Impossible d'envoyer le dernier signalement",

View File

@ -1,3 +1,4 @@
import moment from 'moment';
import Vue from 'vue';
import VueI18n from 'vue-i18n';
@ -58,6 +59,11 @@ if (!locale) {
locale = 'en'; // Safe default
}
if (locale) {
// Set moment locale
moment.locale(locale);
}
export default new VueI18n({
locale,
messages,

View File

@ -1,5 +1,5 @@
import * as api from '@/api';
import { IS_LOADING, IS_DONE_LOADING, PUSH_REPORT, STORE_REPORTS } from './mutations-types';
import { IS_LOADING, IS_DONE_LOADING, PUSH_REPORT, SHOW_REPORT_DETAILS, STORE_REPORTS } from './mutations-types';
export function fetchReports({ commit }) {
commit(IS_LOADING);
@ -8,9 +8,23 @@ export function fetchReports({ commit }) {
.finally(() => commit(IS_DONE_LOADING));
}
export function downvote({ commit }, id) {
return api.downvote(id)
.then(report => commit(PUSH_REPORT, { report }));
}
export function upvote({ commit }, id) {
return api.upvote(id)
.then(report => commit(PUSH_REPORT, { report }));
}
export function saveReport({ commit }, { type, lat, lng }) {
commit(IS_LOADING);
return api.saveReport(type, lat, lng)
.then(report => commit(PUSH_REPORT, { report }))
.finally(() => commit(IS_DONE_LOADING));
}
export function showReportDetails({ commit }, id) {
return commit(SHOW_REPORT_DETAILS, { id });
}

10
src/store/getters.js Normal file
View File

@ -0,0 +1,10 @@
import { REPORT_VOTES_THRESHOLD } from '@/constants';
export function notDismissedReports(state) {
return state.reports.filter((item) => {
if (item.attributes.downvotes === 0) {
return true;
}
return (item.attributes.upvotes / item.attributes.downvotes) > REPORT_VOTES_THRESHOLD;
});
}

View File

@ -4,6 +4,7 @@ import createLogger from 'vuex/dist/logger';
import * as actions from './actions';
import { initialState as state, mutations } from './mutations';
import * as getters from './getters';
const plugins = [];
if (process.NODE_ENV !== 'production') {
@ -14,7 +15,8 @@ Vue.use(Vuex);
export default new Vuex.Store({
actions,
state,
getters,
mutations,
plugins,
state,
});

View File

@ -1,4 +1,5 @@
export const IS_LOADING = 'IS_LOADING';
export const IS_DONE_LOADING = 'IS_DONE_LOADING';
export const PUSH_REPORT = 'PUSH_REPORT';
export const SHOW_REPORT_DETAILS = 'SHOW_REPORT_DETAILS';
export const STORE_REPORTS = 'STORE_REPORTS';

View File

@ -1,7 +1,10 @@
import Vue from 'vue';
import * as types from './mutations-types';
export const initialState = {
isLoading: false,
reportDetailsID: null,
reports: [],
};
@ -12,10 +15,18 @@ export const mutations = {
[types.IS_DONE_LOADING](state) {
state.isLoading = false;
},
[types.SHOW_REPORT_DETAILS](state, { id }) {
state.reportDetailsID = id;
},
[types.STORE_REPORTS](state, { reports }) {
state.reports = reports;
},
[types.PUSH_REPORT](state, { report }) {
const reportIndex = state.reports.findIndex(item => item.id === report.id);
if (reportIndex === -1) {
state.reports.push(report);
} else {
Vue.set(state.reports, reportIndex, report);
}
},
};

View File

@ -8,6 +8,7 @@
</v-flex>
</v-layout>
<v-layout v-else row wrap fill-height>
<ReportCard></ReportCard>
<v-flex xs12 fill-height v-if="latLng">
<Map :positionLatLng="latLng" :heading="heading" :accuracy="accuracy" :markers="reportsMarkers" :onPress="showReportDialog"></Map>
<v-btn
@ -44,6 +45,7 @@
import NoSleep from 'nosleep.js';
import Map from '@/components/Map.vue';
import ReportCard from '@/components/ReportCard.vue';
import ReportDialog from '@/components/ReportDialog/index.vue';
import * as constants from '@/constants';
import { distance, mockLocation } from '@/tools';
@ -51,6 +53,7 @@ import { distance, mockLocation } from '@/tools';
export default {
components: {
Map,
ReportCard,
ReportDialog,
},
beforeDestroy() {
@ -59,10 +62,11 @@ export default {
this.disablePositionWatching();
window.removeEventListener('keydown', this.hideReportDialogOnEsc);
}
this.$store.dispatch('showReportDetails', null);
},
computed: {
reportsMarkers() {
return this.$store.state.reports.map(report => ({
return this.$store.getters.notDismissedReports.map(report => ({
id: report.id,
type: report.attributes.type,
latLng: [report.attributes.lat, report.attributes.lng],

View File

@ -24,6 +24,8 @@
</template>
<script>
import moment from 'moment';
import { messages } from '@/i18n';
import { storageAvailable } from '@/tools';
@ -48,6 +50,8 @@ export default {
localStorage.setItem('preventSuspend', JSON.stringify(this.preventSuspend));
}
this.$i18n.locale = this.i18nSelect;
// Set moment locale
moment.locale(this.i18nSelect);
},
},
};

View File

@ -3826,6 +3826,10 @@ mkdirp@0.5.1, mkdirp@0.5.x, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@
dependencies:
minimist "0.0.8"
moment@^2.22.2:
version "2.22.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"