Add data manipulation functionality to Log component.

It is now possible to add, edit and delete events.
This commit is contained in:
D. Berge
2020-08-22 20:43:23 +02:00
parent 2ff0dfe1fa
commit 2ed52f1a54

View File

@@ -1,19 +1,8 @@
<template> <template>
<v-container fluid> <v-container fluid>
<v-data-table
:headers="headers"
:items="rows"
item-key="ts0"
sort-by="ts0"
:sort-desc="true"
:x-server-items-length="eventCount"
:x-options.sync="options"
:search="filter"
:loading="loading"
:fixed-header="true"
>
<template v-slot:top> <v-card>
<v-card-title>
<v-toolbar flat> <v-toolbar flat>
<v-toolbar-title> <v-toolbar-title>
{{ {{
@@ -28,6 +17,16 @@
: "All events" : "All events"
}} }}
</v-toolbar-title> </v-toolbar-title>
<dougal-event-edit-dialog
v-model="eventDialog"
:allowed-labels="userLabels"
:preset-remarks="presetRemarks"
:default-timestamp="defaultEventTimestamp"
:default-sequence="$route.params.sequence && (Number($route.params.sequence) || Number($route.params.sequence.split(';').sort().pop()))"
@save="saveEvent"
></dougal-event-edit-dialog>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-text-field <v-text-field
v-model="filter" v-model="filter"
@@ -36,38 +35,227 @@
single-line single-line
hide-details></v-text-field> hide-details></v-text-field>
</v-toolbar> </v-toolbar>
</template> </v-card-title>
<v-card-text>
<template v-slot:item.ts0="props"> <v-data-table
<span style="white-space:nowrap;"> :headers="headers"
{{ props.value.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2") }} :items="rows"
item-key="tstamp"
sort-by="tstamp"
:sort-desc="true"
:x-server-items-length="eventCount"
:x-options.sync="options"
:search="filter"
:loading="loading"
fixed-header
>
<template v-slot:item.tstamp="{value}">
<span style="white-space:nowrap;" v-if="value">
{{ value.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2") }}
</span> </span>
</template> </template>
<template v-slot:item.label="props"> <template v-slot:item.remarks="{item}">
<v-chip v-for="label in props.value" <v-edit-dialog v-if="item.items"
large
@save="rowEditorSave"
@cancel="rowEditorCancel"
@open="rowEditorOpen(item)"
@close="rowEditorClose"
> <div v-html="item.items.map(i => i.remarks).join('<br/>')"></div>
<template v-slot:input>
<h3>{{
editedRow.sequence
? `${editedRow.sequence} @ ${editedRow.point}`
: editedRow.tstamp
? editedRow.tstamp.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2")
: editedRow.key
}}</h3><hr/>
<dougal-context-menu
:value="remarksMenu"
@input="addPresetRemark"
:items="presetRemarks"
absolute
></dougal-context-menu>
<template v-for="editedItem in editedRow.items">
<v-text-field
v-model="editedItem.remarks"
label="Edit"
single-line
hide-details="auto"
>
<template v-slot:prepend>
<v-icon v-show="!editedItem.remarks && presetRemarks"
title="Select predefined comments"
color="primary"
@click="(e) => {remarksMenuItem = editedItem; remarksMenu = e}"
>
mdi-dots-vertical
</v-icon>
</template>
<template v-slot:append v-if="editedItem.remarks || editedItem.labels.filter(l => labels[l].model.user).length">
<v-hover v-slot:default="{hover}">
<v-icon
title="Remove comment"
:color="hover ? 'error' : 'error lighten-4'"
@click="removeEvent(editedItem, editedRow)"
>mdi-minus-circle</v-icon>
</v-hover>
</template>
</v-text-field>
<v-container>
<v-row no-gutters>
<v-col class="flex-grow-0">
<!-- Add a new label control -->
<v-edit-dialog
large
@save="addLabel(editedItem)"
@cancel="selectedLabels=[]"
>
<v-icon
small small
:color="labels[label].colour" title="Add label"
:title="labels[label].description" >mdi-tag-plus</v-icon>
v-text="label"></v-chip> <template v-slot:input>
<v-autocomplete
:items="availableLabels(editedItem.labels)"
v-model="selectedLabels"
label="Add label"
chips
deletable-chips
multiple
autofocus
@keydown.stop="(e) => {if (e.key == 'Enter') debug(e)}"
@input="labelSearch = null;"
:search-input.sync="labelSearch"
>
<template v-slot:selection="data">
<v-chip
v-bind="data.attrs"
:input-value="data.selected"
small
@click="data.select"
:color="labels[data.item].view.colour"
:title="labels[data.item].view.description"
>{{data.item}}</v-chip>
</template> </template>
</v-autocomplete>
</template>
</v-edit-dialog>
</v-col>
<v-col class="flex-grow-0">
<v-chip-group>
<v-chip v-for="label in editedItem.labels" :key="label"
small
:close="labels[label].model.user"
:color="labels[label].view.colour"
:title="labels[label].view.description"
@click:close="removeLabel(label, editedItem)"
>{{label}}</v-chip>
</v-chip-group>
</v-col>
</v-row>
</v-container>
</template>
<v-icon v-if="editedRow.items.length == 0 || editedRow.items[editedRow.items.length-1].remarks"
color="primary"
title="Add comment"
class="mb-2"
@click="addEvent"
>mdi-plus-circle</v-icon>
</template>
</v-edit-dialog>
<v-edit-dialog v-else
@save="rowEditorSave"
@cancel="rowEditorCancel"
@open="rowEditorOpen"
@close="rowEditorClose"
>
<template v-slot:input>
<v-text-field
v-model="props.item.remarks[0]"
label="Edit"
single-line
></v-text-field>
</template>
</v-edit-dialog>
</template>
<!-- Labels column -->
<template v-slot:item.labels="{item}">
<!-- Existing labels for row -->
<v-chip v-for="label in item.items.map(i => i.labels).flat()"
class="ma-1"
small
:color="labels[label].view.colour"
:title="labels[label].view.description"
:key="label"
@click:close="removeLabel(label, item)"
>{{label}}</v-chip>
</template>
<!-- Actions column (FIXME currently not used) -->
<template v-slot:item.actions="{ item }">
<div style="white-space:nowrap;">
<v-icon v-if="$root.user || true"
small
class="mr-2"
title="View on map"
@click="viewOnMap(item)"
disabled
>
mdi-map
</v-icon>
</div>
</template>
</v-data-table> </v-data-table>
</v-card-text>
</v-card>
</v-container> </v-container>
</template> </template>
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import DougalContextMenu from '@/components/context-menu';
import DougalEventEditDialog from '@/components/event-edit-dialog'
function ArraysEqual (a, b) {
return a.every(i => b.includes(i)) && b.every(i => a.includes(i));
}
function EventKey (e) {
return e.type+e.id;
}
export default { export default {
name: "Log", name: "Log",
components: {
DougalEventEditDialog,
DougalContextMenu
},
data () { data () {
return { return {
headers: [ headers: [
{ {
value: "ts0", value: "tstamp",
text: "Timestamp", text: "Timestamp",
width: "20ex" width: "20ex"
}, },
@@ -78,7 +266,7 @@ export default {
width: "10ex" width: "10ex"
}, },
{ {
value: "shot_number", value: "point",
text: "Shotpoint", text: "Shotpoint",
align: "end", align: "end",
width: "10ex" width: "10ex"
@@ -89,16 +277,35 @@ export default {
width: "100%" width: "100%"
}, },
{ {
value: "label", value: "labels",
text: "Labels" text: "Labels"
}, },
{
value: "actions",
text: "Actions",
sortable: false
}
], ],
items: [], items: [],
labels: {}, labels: {},
options: {}, options: {},
filter: "", filter: "",
loading: false, loading: false,
eventCount: null eventCount: null,
eventDialog: false,
defaultEventTimestamp: null,
presetRemarks: null,
remarksMenu: null,
remarksMenuItem: null,
editedRow: {
key: null,
tstamp: null,
sequence: null,
point: null,
items: []
},
selectedLabels: [],
labelSearch: null
} }
}, },
@@ -106,25 +313,32 @@ export default {
rows () { rows () {
const rows = {}; const rows = {};
this.items.forEach(i => { this.items.forEach(i => {
const key = i.ts0 || (i.sequence+"@"+i.shot_number); const key = i.tstamp || (i.sequence+"@"+i.point);
if (!rows[key]) { if (!rows[key]) {
rows[key] = Object.assign({}, i); rows[key] = {
rows[key].label = [ ]; key,
rows[key].remarks = [ ]; tstamp: i.tstamp,
sequence: i.sequence,
point: i.point,
items: []
}
} }
const row = rows[key]; const row = rows[key];
if (i.remarks) { row.items.push(i);
row.remarks.push(i.remarks);
}
if (i.label) {
row.label.push(i.label);
}
}); });
return Object.values(rows); return Object.values(rows);
},
userLabels () {
const filtered = {};
for (const key in this.labels) {
if (this.labels[key].model.user) {
filtered[key] = this.labels[key];
}
}
return filtered;
} }
}, },
@@ -134,11 +348,22 @@ export default {
//this.getLines(); //this.getLines();
}, },
deep: true deep: true
},
eventDialog (val) {
if (val) {
// If not online
this.defaultEventTimestamp = Date.now();
}
} }
}, },
methods: { methods: {
debug (value) {
console.log("DEBUG", value);
},
async getEventCount () { async getEventCount () {
//this.eventCount = await this.api([`/project/${this.$route.params.project}/event/?count`]); //this.eventCount = await this.api([`/project/${this.$route.params.project}/event/?count`]);
this.eventCount = null; this.eventCount = null;
@@ -171,20 +396,263 @@ export default {
this.loading = false; this.loading = false;
}, },
async getLabels () { async getLabelDefinitions () {
const url = `/project/${this.$route.params.project}/label`; const url = `/project/${this.$route.params.project}/label`;
const labelSet = {};
const labels = await this.api([url]) || []; const labels = await this.api([url]) || [];
labels.forEach( l => this.labels[l.name] = l.data ); labels.forEach( l => labelSet[l.name] = l.data );
this.labels = labelSet;
}, },
...mapActions(["api"]) async getPresetRemarks () {
const url = `/project/${this.$route.params.project}/configuration/events/presetRemarks`;
this.presetRemarks = await this.api([url]);
},
newItem (from = {}) {
const type = (from.sequence && from.point) ? "sequence" : "timed";
const tstamp = from.tstamp || (new Date).toISOString();
const sequence = from.sequence || null; // FIXME TODO Use vuex
const point = from.point || null; // FIXME TODO Use vuex
const geometry = from.geometry || null; // FIXME TODO Use vuex
return {
type,
id: null,
tstamp,
sequence,
point,
remarks: "",
labels: [],
geometry
};
},
async saveEvent (event) {
console.log("Saving", event);
const url = `/project/${this.$route.params.project}/event`;
await this.api([url, {
method: "POST",
body: event
}]);
this.showSnack(["New event saved", "success"]);
this.getEvents();
console.log("Done");
},
saveLabels (event) {
console.log("saveLabels", event);
},
rowEditorOpen (row) {
console.log(row);
this.editedRow = JSON.parse(JSON.stringify(row));
for (const item of this.editedRow.items) {
if (item.type != "timed" && item.type != "sequence") {
if (item.sequence && item.point) {
item.type = "sequence";
} else {
item.type = "timed";
}
}
}
},
rowEditorClose () {
},
async rowEditorSave () {
// Helper returns a callback that checks if two events have the same key
function within (item) {
return function (i) {
return EventKey(i) == EventKey(item);
}
}
const originalRow = this.rows.find(r => r.key == this.editedRow.key);
if (!originalRow) {
this.showSnack(["Cannot find the original item that was to be edited!", "error"]);
return;
}
console.log("rowEditorSave", this.editedRow, originalRow);
const promises = [];
for (const editedItem of this.editedRow.items) {
// Discard non user writable labels
editedItem.labels = editedItem.labels.filter(l => this.labels[l].model.user);
// Try to find this event in originalRow
const originalItem = originalRow.items.find(within(editedItem));
if (originalItem) {
// If found, check to see if its remarks or labels have changed
if (originalItem.remarks != editedItem.remarks || !ArraysEqual(originalItem.labels, editedItem.labels)) {
// This item has been modified
console.log("PUT modified item", editedItem, originalItem);
const url = `/project/${this.$route.params.project}/event/${editedItem.type}/${editedItem.id}/`;
const request = this.api([url, {
method: "PUT",
body: editedItem
}]);
promises.push(request);
} // Else, the item was not modified
} else {
// If not found, this is a new event
if (!editedItem.remarks.trim() && !editedItem.labels.length) {
// There is nothing to post, discard
console.log("Discard empty event", editedItem);
continue;
}
console.log("POST new item", editedItem);
const url = `/project/${this.$route.params.project}/event/`;
const request = this.api([url, {
method: "POST",
body: editedItem
}]);
promises.push(request);
}
}
for (const originalItem of originalRow.items) {
if (originalItem.virtual) {
continue;
}
// Try to find this event in editedRow
const editedItem = this.editedRow.items.find(within(originalItem));
if (!editedItem) {
// If not found, it has been deleted
console.log("DELETE old item", originalItem);
const url = `/project/${this.$route.params.project}/event/${originalItem.type}/${originalItem.id}/`;
const request = this.api([url, {method: "DELETE"}]);
promises.push(request);
}
}
this.rowEditorCancel();
if (promises.length) {
this.showSnack(["Saving data…", "info"]);
await Promise.all(promises);
this.showSnack(["The changes have been saved", "success"]);
this.getEvents();
}
},
rowEditorCancel () {
this.$nextTick( () => {
this.editedRow = {
key: null,
tstamp: null,
sequence: null,
point: null,
items: []
}
});
},
addEvent () {
const count = this.editedRow.items.length;
const newItem = this.newItem(this.editedRow.items[count-1]);
this.editedRow.items.push(newItem);
},
removeEvent (event, row) {
const index = row.items.indexOf(event);
if (index >= 0) {
console.log(JSON.parse(JSON.stringify(event)));
if (!event.virtual) {
// A virtual event will not be deletable so we can't
// remove it
row.items.splice(index, 1);
} else {
// What we do instead is clear any user-entered info
event.remarks = "";
event.labels = event.labels.filter(l => !this.labels[l].model.user);
}
}
},
addPresetRemark ({text}) {
if (this.remarksMenuItem) {
this.remarksMenuItem.remarks = text;
}
this.remarksMenuItem = null;
},
availableLabels (usedLabels) {
return Object.keys(this.labels)
.filter(k => !usedLabels.includes(k) && this.labels[k].model.user);
},
addLabel (editedItem) {
console.log("addLabel", this.selectedLabels, editedItem);
this.selectedLabels.forEach(label => {
if (!editedItem.labels.includes(label)) {
editedItem.labels.push(label);
}
});
this.selectedLabels = [];
},
removeLabel (label, item) {
console.log("removeLabel", label, item);
item.labels = item.labels.filter(l => l != label);
/*
const url = `/project/${this.$route.params.project}/event/${item.type}/${item.id}/label/${label}`;
this.api([url, {method: "DELETE"}])
.then( () => this.showSnack([`Label ${item.label} removed`, "info"]) );
*/
},
handleKeyboardEvent (e) {
if (e.ctrlKey && !e.altKey && !e.shiftKey && (e.keyCode == 13 || e.key == "F2")) {
console.log("Add timed event if offline or shot event if online");
this.eventDialog = true;
} else if (e.ctrlKey && !e.altKey && e.shiftKey && (e.keyCode == 13 || e.key == "F2")) {
console.log("Add timed event (even if online)");
this.eventDialog = true;
}
},
...mapActions(["api", "showSnack"])
}, },
async mounted () { async mounted () {
await this.getLabels() await this.getLabelDefinitions()
this.getEventCount(); this.getEventCount();
this.getEvents(); this.getEvents();
this.getPresetRemarks();
window.addEventListener('keyup', this.handleKeyboardEvent);
},
beforeDestroy () {
window.removeEventListener('keyup', this.handleKeyboardEvent);
} }
} }