Support description for location points.
This commit is contained in:
parent
eb9e6abd22
commit
53a9a2afeb
12 changed files with 275 additions and 28 deletions
|
@ -84,6 +84,10 @@ ADMIN_EMAIL=admin@example.com
|
|||
# Comment or leave empty if the postal code is not available.
|
||||
# DB_LOCATION__POSTAL_CODE=postalCode
|
||||
|
||||
# The name of the column that contains the description of each location point
|
||||
# Comment or leave empty if the description is not available.
|
||||
# DB_LOCATION__DESCRIPTION=description
|
||||
|
||||
###
|
||||
### Frontend configuration.
|
||||
### This is only required if you want to run the frontend in development mode
|
||||
|
|
|
@ -30,7 +30,8 @@
|
|||
<PointInfo :point="selectedPoint"
|
||||
ref="popup"
|
||||
@remove="onRemove"
|
||||
@close="selectedPoint = null" />
|
||||
@edit="editPoint"
|
||||
@close="clearSelectedPoint" />
|
||||
|
||||
<div class="controls">
|
||||
<div class="form-container" v-if="showControls">
|
||||
|
@ -135,6 +136,7 @@ export default {
|
|||
routesLayer: null as Optional<VectorLayer>,
|
||||
selectedFeature: null as Optional<Feature>,
|
||||
selectedPoint: null as Optional<GPSPoint>,
|
||||
selectedPointIndex: null as Optional<number>,
|
||||
showControls: false,
|
||||
showMetrics: new TimelineMetricsConfiguration(),
|
||||
}
|
||||
|
@ -206,11 +208,20 @@ export default {
|
|||
} finally {
|
||||
this.loading = false
|
||||
this.pointToRemove = null
|
||||
this.selectedPoint = null
|
||||
this.selectedFeature = null
|
||||
this.clearSelectedPoint()
|
||||
}
|
||||
},
|
||||
|
||||
async editPoint(value: GPSPoint) {
|
||||
const index = this.selectedPointIndex
|
||||
if (index === null) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.updatePoints([value])
|
||||
this.gpsPoints[index] = value
|
||||
},
|
||||
|
||||
onRemove(point: GPSPoint) {
|
||||
this.pointToRemove = point
|
||||
},
|
||||
|
@ -273,9 +284,14 @@ export default {
|
|||
|
||||
if (feature) {
|
||||
this.selectedFeature = feature as Feature
|
||||
const point = this.gpsPoints.find((gps: GPSPoint) => {
|
||||
const point = this.gpsPoints.find((gps: GPSPoint, index: number) => {
|
||||
const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates()
|
||||
return gps.longitude === longitude && gps.latitude === latitude
|
||||
if (gps.longitude === longitude && gps.latitude === latitude) {
|
||||
this.selectedPointIndex = index
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if (point) {
|
||||
|
@ -286,12 +302,17 @@ export default {
|
|||
map.getView().setCenter(event.coordinate)
|
||||
}
|
||||
} else {
|
||||
this.selectedPoint = null
|
||||
this.selectedFeature = null
|
||||
this.clearSelectedPoint()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearSelectedPoint() {
|
||||
this.selectedPoint = null
|
||||
this.selectedPointIndex = null
|
||||
this.selectedFeature = null
|
||||
},
|
||||
|
||||
refreshShowMetricsFromURL() {
|
||||
this.showMetrics = new TimelineMetricsConfiguration(this.parseQuery(window.location.href))
|
||||
},
|
||||
|
|
|
@ -22,6 +22,34 @@
|
|||
<font-awesome-icon icon="fas fa-mountain" />
|
||||
{{ Math.round(point.altitude) }} m
|
||||
</p>
|
||||
|
||||
<form class="description editor" @submit.prevent="editPoint" v-if="editDescription">
|
||||
<div class="row">
|
||||
<textarea
|
||||
:value="point.description"
|
||||
@keydown.enter="editPoint"
|
||||
@blur="onDescriptionBlur"
|
||||
ref="description"
|
||||
placeholder="Enter a description" />
|
||||
|
||||
<button type="submit" title="Save">
|
||||
<font-awesome-icon icon="fas fa-save" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="description"
|
||||
:class="{ 'no-content': !point.description?.length }"
|
||||
@click="editDescription = true"
|
||||
v-else>
|
||||
<span class="icon">
|
||||
<font-awesome-icon icon="fas fa-edit" />
|
||||
</span>
|
||||
<span class="text">
|
||||
{{ point.description?.length ? point.description : 'No description' }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p class="locality" v-if="point.locality">{{ point.locality }}</p>
|
||||
<p class="postal-code" v-if="point.postalCode">{{ point.postalCode }}</p>
|
||||
<p class="country" v-if="country">
|
||||
|
@ -51,7 +79,7 @@ import Dates from '../mixins/Dates.vue';
|
|||
import GPSPoint from '../models/GPSPoint';
|
||||
|
||||
export default {
|
||||
emit: ['close', 'remove'],
|
||||
emit: ['close', 'edit', 'remove'],
|
||||
mixins: [Dates],
|
||||
props: {
|
||||
point: {
|
||||
|
@ -61,6 +89,8 @@ export default {
|
|||
|
||||
data() {
|
||||
return {
|
||||
newValue: null,
|
||||
editDescription: false,
|
||||
popup: null as Overlay | null,
|
||||
}
|
||||
},
|
||||
|
@ -99,12 +129,44 @@ export default {
|
|||
map.addOverlay(this.popup)
|
||||
},
|
||||
|
||||
editPoint() {
|
||||
this.newValue.description = (this.$refs.description as HTMLTextAreaElement).value
|
||||
this.$emit('edit', this.newValue)
|
||||
this.editDescription = false
|
||||
},
|
||||
|
||||
onDescriptionBlur() {
|
||||
// Give it a moment to allow relevant click events to trigger
|
||||
setTimeout(() => {
|
||||
this.editDescription = false
|
||||
}, 100)
|
||||
},
|
||||
|
||||
setPosition(coordinates: number[]) {
|
||||
if (this.popup) {
|
||||
this.popup.setPosition(coordinates)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
point: {
|
||||
immediate: true,
|
||||
handler(point: GPSPoint | null) {
|
||||
if (point) {
|
||||
this.newValue = point
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
editDescription(edit: boolean) {
|
||||
if (edit) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.description?.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -128,7 +190,7 @@ export default {
|
|||
.header {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
right: 0;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
|
@ -158,6 +220,57 @@ export default {
|
|||
margin: -0.25em 0 0.25em 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
.icon {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&:not(.no-content) {
|
||||
.text {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
&.no-content {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5em;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
font-size: 0.9em;
|
||||
margin: 0;
|
||||
padding: 0.5em 1.5em;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-heading);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: var(--color-heading);
|
||||
font-weight: bold;
|
||||
|
|
|
@ -12,13 +12,13 @@ export default {
|
|||
}) || []
|
||||
|
||||
return points.map((gps: any) =>
|
||||
new GPSPoint({
|
||||
...gps,
|
||||
// Normalize timestamp to Date object
|
||||
timestamp: new Date(gps.timestamp),
|
||||
})
|
||||
)
|
||||
.sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
|
||||
new GPSPoint({
|
||||
...gps,
|
||||
// Normalize timestamp to Date object
|
||||
timestamp: new Date(gps.timestamp),
|
||||
})
|
||||
)
|
||||
.sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
|
||||
},
|
||||
|
||||
async deletePoints(points: GPSPoint[]) {
|
||||
|
@ -27,6 +27,13 @@ export default {
|
|||
body: points.map((point: GPSPoint) => point.id),
|
||||
})
|
||||
},
|
||||
|
||||
async updatePoints(points: GPSPoint[]) {
|
||||
await this.request('/gpsdata', {
|
||||
method: 'PATCH',
|
||||
body: points,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -3,22 +3,50 @@ class GPSPoint {
|
|||
public latitude: number;
|
||||
public longitude: number;
|
||||
public altitude: number;
|
||||
public deviceId: string;
|
||||
public address: string;
|
||||
public locality: string;
|
||||
public country: string;
|
||||
public postalCode: string;
|
||||
public description?: string;
|
||||
public timestamp: Date;
|
||||
|
||||
constructor(public data: any) {
|
||||
this.id = data.id;
|
||||
this.latitude = data.latitude;
|
||||
this.longitude = data.longitude;
|
||||
this.altitude = data.altitude;
|
||||
this.address = data.address;
|
||||
this.locality = data.locality;
|
||||
this.country = data.country;
|
||||
this.postalCode = data.postalCode;
|
||||
this.timestamp = data.timestamp;
|
||||
constructor({
|
||||
id,
|
||||
latitude,
|
||||
longitude,
|
||||
altitude,
|
||||
deviceId,
|
||||
address,
|
||||
locality,
|
||||
country,
|
||||
postalCode,
|
||||
description,
|
||||
timestamp,
|
||||
}: {
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
altitude: number;
|
||||
deviceId: string;
|
||||
address: string;
|
||||
locality: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
description?: string;
|
||||
timestamp: Date;
|
||||
}) {
|
||||
this.id = id;
|
||||
this.latitude = latitude;
|
||||
this.longitude = longitude;
|
||||
this.altitude = altitude;
|
||||
this.deviceId = deviceId;
|
||||
this.address = address;
|
||||
this.locality = locality;
|
||||
this.country = country;
|
||||
this.postalCode = postalCode;
|
||||
this.description = description;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -94,7 +94,8 @@ class Db {
|
|||
'address',
|
||||
'locality',
|
||||
'country',
|
||||
'postalCode'
|
||||
'postalCode',
|
||||
'description',
|
||||
].reduce((acc: any, name: string) => {
|
||||
acc[name] = process.env[this.prefixedEnv(name)];
|
||||
if (!acc[name]?.length && (requiredColumns[name] || opts.locationUrl === opts.url)) {
|
||||
|
|
|
@ -218,6 +218,10 @@ async function createLocationHistoryTable(query: { context: any }) {
|
|||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
[$db.locationTableColumns['description']]: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
[$db.locationTableColumns['timestamp']]: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
|
|
|
@ -66,6 +66,14 @@ function GPSData(locationTableColumns: Record<string, string>): Record<string, a
|
|||
};
|
||||
}
|
||||
|
||||
const descriptionCol: string = locationTableColumns['description'];
|
||||
if (descriptionCol?.length) {
|
||||
typeDef[descriptionCol] = {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
};
|
||||
}
|
||||
|
||||
typeDef[locationTableColumns['timestamp']] = {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
|
|
|
@ -8,6 +8,7 @@ class GPSPoint {
|
|||
public locality: string | null;
|
||||
public country: string | null;
|
||||
public postalCode: string | null;
|
||||
public description: string | null;
|
||||
public timestamp: Date;
|
||||
|
||||
constructor(record: any) {
|
||||
|
@ -20,6 +21,7 @@ class GPSPoint {
|
|||
this.locality = record.locality;
|
||||
this.country = record.country;
|
||||
this.postalCode = record.postalCode;
|
||||
this.description = record.description;
|
||||
this.timestamp = record.timestamp;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ class Location {
|
|||
locality: data[mappings.locality],
|
||||
country: data[mappings.country],
|
||||
postalCode: data[mappings.postalCode],
|
||||
description: data[mappings.description],
|
||||
timestamp: data[mappings.timestamp],
|
||||
});
|
||||
});
|
||||
|
@ -62,6 +63,7 @@ class Location {
|
|||
locality: data[mappings.locality],
|
||||
country: data[mappings.country],
|
||||
postalCode: data[mappings.postalCode],
|
||||
description: data[mappings.description],
|
||||
timestamp: data[mappings.timestamp],
|
||||
});
|
||||
});
|
||||
|
@ -94,6 +96,7 @@ class Location {
|
|||
[mappings.locality]: p.locality,
|
||||
[mappings.country]: p.country,
|
||||
[mappings.postalCode]: p.postalcode,
|
||||
[mappings.description]: p.description,
|
||||
[mappings.timestamp]: p.timestamp
|
||||
}
|
||||
},
|
||||
|
@ -111,6 +114,7 @@ class Location {
|
|||
locality: data[mappings.locality],
|
||||
country: data[mappings.country],
|
||||
postalCode: data[mappings.postalCode],
|
||||
description: data[mappings.description],
|
||||
timestamp: data[mappings.timestamp],
|
||||
});
|
||||
});
|
||||
|
@ -119,6 +123,41 @@ class Location {
|
|||
}
|
||||
}
|
||||
|
||||
public async updatePoints(points: GPSPoint[]): Promise<void> {
|
||||
const mappings: any = $db.locationTableColumns;
|
||||
// Lowercase the keys of the mappings object -
|
||||
// some databases are case-insensitive and this will help with consistency
|
||||
const normalizedPoints = points.map((p) =>
|
||||
Object.entries(p).reduce((acc, [key, value]) => {
|
||||
acc[key.toLowerCase()] = value;
|
||||
return acc;
|
||||
} , {} as Record<string, any>)
|
||||
);
|
||||
|
||||
try {
|
||||
await $db.GPSData().bulkCreate(
|
||||
normalizedPoints.map((p) => {
|
||||
return {
|
||||
[mappings.id]: p.id,
|
||||
[mappings.deviceId]: p.deviceid,
|
||||
[mappings.latitude]: p.latitude,
|
||||
[mappings.longitude]: p.longitude,
|
||||
[mappings.altitude]: p.altitude,
|
||||
[mappings.address]: p.address,
|
||||
[mappings.locality]: p.locality,
|
||||
[mappings.country]: p.country,
|
||||
[mappings.postalCode]: p.postalcode,
|
||||
[mappings.description]: p.description,
|
||||
[mappings.timestamp]: p.timestamp
|
||||
}
|
||||
}),
|
||||
{ updateOnDuplicate: Object.keys(mappings) }
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`Error updating data: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async deletePoints(points: number[]): Promise<void> {
|
||||
try {
|
||||
await $db.GPSData().destroy({
|
||||
|
|
|
@ -14,6 +14,7 @@ class LocationRequest {
|
|||
country: Optional<string> = null;
|
||||
locality: Optional<string> = null;
|
||||
postalCode: Optional<string> = null;
|
||||
description: Optional<string> = null;
|
||||
orderBy: string = 'timestamp';
|
||||
order: string = 'DESC';
|
||||
|
||||
|
@ -27,6 +28,7 @@ class LocationRequest {
|
|||
this.country = req.country;
|
||||
this.locality = req.locality;
|
||||
this.postalCode = req.postalCode;
|
||||
this.description = req.description;
|
||||
this.orderBy = req.orderBy || this.orderBy;
|
||||
this.order = req.order || this.order;
|
||||
}
|
||||
|
@ -95,6 +97,10 @@ class LocationRequest {
|
|||
where[colMapping.postalCode || 'postalCode'] = this.postalCode;
|
||||
}
|
||||
|
||||
if (this.description != null) {
|
||||
where[colMapping.description || 'description'] = {[Op.like]: `%${this.description}%`};
|
||||
}
|
||||
|
||||
queryMap.where = where;
|
||||
queryMap.order = [[colMapping[this.orderBy], this.order.toUpperCase()]];
|
||||
return queryMap;
|
||||
|
|
|
@ -4,7 +4,7 @@ import { authenticate } from '../../../auth';
|
|||
import { AuthInfo } from '../../../auth';
|
||||
import { LocationRequest } from '../../../requests';
|
||||
import { Optional } from '../../../types';
|
||||
import { RoleName } from '../../../models';
|
||||
import { GPSPoint, RoleName } from '../../../models';
|
||||
import ApiV1Route from './Route';
|
||||
|
||||
class GPSData extends ApiV1Route {
|
||||
|
@ -48,6 +48,20 @@ class GPSData extends ApiV1Route {
|
|||
res.status(201).send();
|
||||
}
|
||||
|
||||
@authenticate()
|
||||
patch = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||
const points = (req.body as GPSPoint[]).map((p) => {
|
||||
const descr = p.description?.trim()
|
||||
p.description = descr?.length ? descr : null;
|
||||
return p;
|
||||
});
|
||||
|
||||
const deviceIds = points.map((p: any) => p.deviceId).filter((d: any) => !!d);
|
||||
this.validateOwnership(deviceIds, auth!);
|
||||
await $repos.location.updatePoints(points);
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
@authenticate()
|
||||
delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||
const pointIds = req.body as number[];
|
||||
|
|
Loading…
Add table
Reference in a new issue