Compare commits

...

12 Commits

Author SHA1 Message Date
D. Berge
61602e3799 Add control to filter out archived projects in ProjectList 2023-10-29 13:25:37 +01:00
D. Berge
44fe836dfa Return the archived project configuration value.
This value indicates whether the project should receive updates
from external sources.
2023-10-29 13:20:02 +01:00
D. Berge
a74fd56f15 Merge branch '286-support-structured-data-log-entries' into 'devel'
Resolve "Support structured data log entries"

Closes #286

See merge request wgp/dougal/software!51
2023-10-28 18:06:08 +00:00
D. Berge
a369f2dd7b Add structured values support to <dougal-event-edit/> 2023-10-28 20:02:13 +02:00
D. Berge
83c3f9b401 Add <dougal-event-select/> component.
This is a refactoring of <dougal-event-edit/> focusing on the
preset remark selection combo box and context menu with the
addition of support for structured values via the
<dougal-event-properties/> component.
2023-10-28 19:57:56 +02:00
D. Berge
798178af55 Add <dougal-event-properties/> component.
It provides an input form for structured values.
2023-10-28 19:56:08 +02:00
D. Berge
320f67fd06 Merge branch '285-refactor-navigation-bar' into 'devel'
Resolve "Refactor navigation bar"

Closes #285

See merge request wgp/dougal/software!50
2023-10-28 11:29:31 +00:00
D. Berge
9f2e25278b Replace hard-coded navigation bar with dynamic alternative.
Navigation bars should be coded as their own component and
added to the meta section of the Vue Router's route(s) in which
they are to be used.
2023-10-28 13:26:57 +02:00
D. Berge
d2f8444042 Add <v-app-bar/> extension info to router.
The idea is for the <dougal-navigation/> component to dynamically
load the extension, if any, defined in the route's meta attribute.
2023-10-28 13:24:56 +02:00
D. Berge
28beef81de Create new <v-app-bar/> extension component.
Intended to be used in the v-slot:extension slot of <v-app-bar/>.
2023-10-28 13:22:58 +02:00
D. Berge
9a2fdeab0e Fix typo in SQL query.
Fixes #284.
2023-10-28 12:06:42 +02:00
D. Berge
9a3cf7997e Track changes to projects list.
The main application component listens for project events and
updates the Vuex store when a project related event is seen.
2023-10-28 11:25:37 +02:00
10 changed files with 458 additions and 95 deletions

View File

@@ -35,7 +35,7 @@
</style> </style>
<script> <script>
import { mapActions } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import DougalNavigation from './components/navigation'; import DougalNavigation from './components/navigation';
import DougalFooter from './components/footer'; import DougalFooter from './components/footer';
@@ -53,7 +53,8 @@ export default {
computed: { computed: {
snackText () { return this.$store.state.snack.snackText }, snackText () { return this.$store.state.snack.snackText },
snackColour () { return this.$store.state.snack.snackColour } snackColour () { return this.$store.state.snack.snackColour },
...mapGetters(["serverEvent"])
}, },
watch: { watch: {
@@ -75,17 +76,25 @@ export default {
if (!newVal) { if (!newVal) {
this.$store.commit('setSnackText', ""); this.$store.commit('setSnackText', "");
} }
},
async serverEvent (event) {
if (event.channel == "project" && event.payload?.schema == "public") {
// Projects changed in some way or another
await this.refreshProjects();
}
} }
}, },
methods: { methods: {
...mapActions(["setCredentials"]) ...mapActions(["setCredentials", "refreshProjects"])
}, },
mounted () { async mounted () {
// Local Storage values are always strings // Local Storage values are always strings
this.$vuetify.theme.dark = localStorage.getItem("darkTheme") == "true"; this.$vuetify.theme.dark = localStorage.getItem("darkTheme") == "true";
this.setCredentials() await this.setCredentials();
this.refreshProjects();
} }
}; };

View File

@@ -0,0 +1,48 @@
<template>
<v-tabs :value="tab" show-arrows>
<v-tab v-for="tab, index in tabs" :key="index" link :to="tabLink(tab.href)" v-text="tab.text"></v-tab>
</v-tabs>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'DougalAppBarExtensionProject',
data() {
return {
tabs: [
{ href: "summary", text: "Summary" },
{ href: "lines", text: "Lines" },
{ href: "plan", text: "Plan" },
{ href: "sequences", text: "Sequences" },
{ href: "calendar", text: "Calendar" },
{ href: "log", text: "Log" },
{ href: "qc", text: "QC" },
{ href: "graphs", text: "Graphs" },
{ href: "map", text: "Map" }
]
};
},
computed: {
page () {
return this.$route.path.split(/\/+/)[3];
},
tab () {
return this.tabs.findIndex(t => t.href == this.page);
},
},
methods: {
tabLink (href) {
return `/projects/${this.$route.params.project}/${href}`;
}
}
}
</script>

View File

@@ -123,29 +123,11 @@
<v-row dense> <v-row dense>
<v-col cols="12"> <v-col cols="12">
<v-combobox <dougal-event-select
ref="remarks" v-bind.sync="entryRemarks"
v-model="entryRemarks" :preset-remarks="presetRemarks"
:disabled="loading" @update:labels="(v) => this.entryLabels = v"
:search-input.sync="entryRemarksInput" ></dougal-event-select>
:items="remarksAvailable"
:filter="searchRemarks"
item-text="text"
return-object
label="Remarks"
hint="Placeholders: @DMS@, @DEG@, @EN@, @WD@, @BSP@, @CMG@, …"
prepend-icon="mdi-text-box-outline"
append-outer-icon="mdi-magnify"
@click:append-outer="(e) => remarksMenu = e"
></v-combobox>
<dougal-context-menu
:value="remarksMenu"
@input="handleRemarksMenu"
:items="presetRemarks"
absolute
></dougal-context-menu>
</v-col> </v-col>
</v-row> </v-row>
@@ -290,6 +272,7 @@
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import DougalContextMenu from '@/components/context-menu'; import DougalContextMenu from '@/components/context-menu';
import DougalEventSelect from '@/components/event-select';
function stringSort (a, b) { function stringSort (a, b) {
return a == b return a == b
@@ -308,6 +291,7 @@ function flattenRemarks(items, keywords=[], labels=[]) {
if (!item.items) { if (!item.items) {
result.push({ result.push({
text: item.text, text: item.text,
properties: item.properties,
labels: labels.concat(item.labels??[]), labels: labels.concat(item.labels??[]),
keywords keywords
}) })
@@ -342,7 +326,8 @@ export default {
name: 'DougalEventEdit', name: 'DougalEventEdit',
components: { components: {
DougalContextMenu DougalContextMenu,
DougalEventSelect
}, },
props: { props: {
@@ -354,6 +339,7 @@ export default {
sequence: { type: Number }, sequence: { type: Number },
point: { type: Number }, point: { type: Number },
remarks: { type: String }, remarks: { type: String },
meta: { type: Object },
labels: { type: Array, default: () => [] }, labels: { type: Array, default: () => [] },
latitude: { type: Number }, latitude: { type: Number },
longitude: { type: Number }, longitude: { type: Number },
@@ -371,18 +357,11 @@ export default {
entrySequence: null, entrySequence: null,
entryPoint: null, entryPoint: null,
entryRemarks: null, entryRemarks: null,
entryRemarksInput: null,
entryLatitude: null, entryLatitude: null,
entryLongitude: null entryLongitude: null
}), }),
computed: { computed: {
remarksAvailable () {
return this.entryRemarksInput == this.entryRemarks?.text ||
this.entryRemarksInput == this.entryRemarks
? []
: flattenRemarks(this.presetRemarks);
},
allSelected () { allSelected () {
return this.entryLabels.length === this.items.length return this.entryLabels.length === this.items.length
@@ -394,11 +373,6 @@ export default {
return true; return true;
} }
// The user is editing the remarks
if (this.entryRemarksText != this.entryRemarksInput) {
return true;
}
// Selected label set distinct from input labels // Selected label set distinct from input labels
if (distinctSets(this.selectedLabels, this.entryLabels, (i) => i.text)) { if (distinctSets(this.selectedLabels, this.entryLabels, (i) => i.text)) {
return true; return true;
@@ -502,11 +476,8 @@ export default {
this.entrySequence = this.sequence; this.entrySequence = this.sequence;
this.entryPoint = this.point; this.entryPoint = this.point;
this.entryRemarks = this.remarks;
this.entryLabels = [...(this.labels??[])]; this.entryLabels = [...(this.labels??[])];
this.makeEntryRemarks();
// Focus remarks field
this.$nextTick(() => this.$refs.remarks.focus());
} }
}, },
@@ -577,22 +548,13 @@ export default {
}; };
}, },
searchRemarks (item, queryText, itemText) { makeEntryRemarks () {
const needle = queryText.toLowerCase(); this.entryRemarks = {
const text = item.text.toLowerCase(); template: null,
const keywords = item.keywords.map(i => i.toLowerCase()); schema: {},
const labels = item.labels.map(i => i.toLowerCase()); values: [],
return text.includes(needle) || ...this.meta?.structured_values,
keywords.some(i => i.includes(needle)) || text: this.remarks
labels.some(i => i.includes(needle));
},
handleRemarksMenu (event) {
if (typeof event == 'boolean') {
this.remarksMenu = event;
} else {
this.entryRemarks = event;
this.remarksMenu = false;
} }
}, },
@@ -657,14 +619,24 @@ export default {
save () { save () {
// In case the focus goes directly from the remarks field // In case the focus goes directly from the remarks field
// to the Save button. // to the Save button.
if (this.entryRemarksInput != this.entryRemarksText) {
this.entryRemarks = this.entryRemarksInput; let meta;
if (this.entryRemarks.values?.length) {
meta = {
structured_values: {
template: this.entryRemarks.template,
schema: this.entryRemarks.schema,
values: this.entryRemarks.values
}
};
} }
const data = { const data = {
id: this.id, id: this.id,
remarks: this.entryRemarksText, remarks: this.entryRemarksText,
labels: this.entryLabels labels: this.entryLabels,
meta
}; };
/* NOTE This is the purist way. /* NOTE This is the purist way.

View File

@@ -0,0 +1,142 @@
<template>
<v-card flat>
<v-card-subtitle v-text="text">
</v-card-subtitle>
<v-card-text style="max-height:350px;overflow:scroll;">
<v-form>
<template v-for="key in fieldKeys">
<template v-if="schema[key].enum">
<v-select v-if="schema[key].type == 'number'" :key="key"
v-model.number="fieldValues[key]"
:items="schema[key].enum"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, Number($event))"
></v-select>
<v-select v-else :key="key"
v-model="fieldValues[key]"
:items="schema[key].enum"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
></v-select>
</template>
<template v-else>
<v-text-field v-if="schema[key].type == 'number'" :key="key"
v-model.number="fieldValues[key]"
type="number"
:min="schema[key].minimum"
:max="schema[key].maximum"
:step="schema[key].multiplier"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, Number($event))"
>
</v-text-field>
<v-text-field v-else-if="schema[key].type == 'string'" :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
>
</v-text-field>
<v-checkbox v-else-if="schema[key].type == 'boolean'" :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@change="updateFieldValue(key, $event)"
>
</v-checkbox>
<v-text-field v-else :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
>
</v-text-field>
</template>
</template>
</v-form>
</v-card-text>
</v-card>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: "DougalEventPropertiesEdit",
components: {
},
props: {
value: String,
template: String,
schema: Object,
values: Array
},
data () {
return {
}
},
computed: {
fieldKeys () {
return Object.entries(this.schema).sort((a, b) => a[1].title > b[1].title ? 1 : -1).map(i => i[0]);
},
fieldValues () {
const keys = Object.keys(this.schema ?? this.values);
return Object.fromEntries(
keys.map( (k, idx) =>
[ k, this.values?.[idx] ?? this.schema[k].default ]));
},
/*
fields () {
// TODO Remove this and rename fields → schema
return this.schema;
},
*/
text () {
if (this.template) {
const rx = /{{([a-z_][a-z0-9_]*)}}/ig;
return this.template.replace(rx, (match, p1) => this.fieldValues[p1] ?? "(n/a)");
}
}
},
watch: {
values () {
this.$emit("input", this.text);
},
template () {
this.$emit("input", this.text);
},
schema () {
this.$emit("input", this.text);
}
},
methods: {
updateFieldValue(key, ev) {
const values = {...this.fieldValues};
values[key] = ev;
this.$emit("update:values", Object.values(values));
}
},
mount () {
}
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div>
<v-combobox
ref="remarks"
:value="text"
@input="handleComboBox"
:search-input.sync="entryRemarksInput"
:items="remarksAvailable"
:filter="searchRemarks"
item-text="text"
return-object
label="Remarks"
hint="Placeholders: @DMS@, @DEG@, @EN@, @WD@, @BSP@, @CMG@, …"
prepend-icon="mdi-text-box-outline"
append-outer-icon="mdi-magnify"
@click:append-outer="(e) => remarksMenu = e"
></v-combobox>
<dougal-context-menu
:value="remarksMenu"
@input="handleRemarksMenu"
:items="presetRemarks"
absolute
></dougal-context-menu>
<v-expansion-panels v-if="haveProperties"
class="px-8"
:value="0"
>
<v-expansion-panel>
<v-expansion-panel-header>Properties</v-expansion-panel-header>
<v-expansion-panel-content>
<dougal-event-properties-edit
:value="text"
@input="$emit('update:text', $event)"
:template="template"
:schema="schema"
:values="values"
@update:values="$emit('update:values', $event)"
>
</dougal-event-properties-edit>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalContextMenu from '@/components/context-menu';
import DougalEventPropertiesEdit from '@/components/event-properties';
export default {
name: "DougalEventSelect",
components: {
DougalContextMenu,
DougalEventPropertiesEdit
},
props: {
text: String,
template: String,
schema: Object,
values: Array,
presetRemarks: Array
},
data () {
return {
entryRemarksInput: null,
remarksMenu: false,
}
},
computed: {
remarksAvailable () {
return this.entryRemarksInput == this.text
? []
: this.flattenRemarks(this.presetRemarks);
},
haveProperties () {
for (const key in this.schema) {
return true;
}
return false;
}
},
watch: {
},
methods: {
flattenRemarks (items, keywords=[], labels=[]) {
const result = [];
if (items) {
for (const item of items) {
if (!item.items) {
result.push({
text: item.text,
properties: item.properties,
labels: labels.concat(item.labels??[]),
keywords
})
} else {
const k = [...keywords, item.text];
const l = [...labels, ...(item.labels??[])];
result.push(...this.flattenRemarks(item.items, k, l))
}
}
}
return result;
},
searchRemarks (item, queryText, itemText) {
const needle = queryText.toLowerCase();
const text = item.text.toLowerCase();
const keywords = item.keywords.map(i => i.toLowerCase());
const labels = item.labels.map(i => i.toLowerCase());
return text.includes(needle) ||
keywords.some(i => i.includes(needle)) ||
labels.some(i => i.includes(needle));
},
handleComboBox (event) {
if (typeof event == "object") {
this.$emit("update:text", event.text);
this.$emit("update:template", event.template ?? event.text);
this.$emit("update:schema", event.properties);
this.$emit("update:labels", event.labels);
} else {
this.$emit("update:text", event);
this.$emit("update:template", null);
this.$emit("update:properties", null);
this.$emit("update:labels", []);
}
},
handleRemarksMenu (event) {
if (typeof event == 'boolean') {
this.remarksMenu = event;
} else {
this.$emit("update:text", event.text);
this.$emit("update:template", event.template ?? event.text);
this.$emit("update:schema", event.properties);
this.$emit("update:labels", event.labels);
this.remarksMenu = false;
}
},
},
mount () {
// Focus remarks field
this.$nextTick(() => this.$refs.remarks.focus());
}
}
</script>

View File

@@ -71,17 +71,10 @@
</v-menu> </v-menu>
<!--
<v-btn small text class="ml-2" title="Log out" link to="/?logout=1">
<v-icon small>mdi-logout</v-icon>
</v-btn>
-->
</template> </template>
</template> </template>
<template v-slot:extension v-if="$route.matched.find(i => i.name == 'Project')"> <template v-slot:extension v-if="appBarExtension">
<v-tabs :value="tab" show-arrows align-with-title> <div :is="appBarExtension"></div>
<v-tab v-for="tab, index in tabs" :key="index" link :to="tabLink(tab.href)" v-text="tab.text"></v-tab>
</v-tabs>
</template> </template>
</v-app-bar> </v-app-bar>
@@ -95,24 +88,17 @@ export default {
data() { data() {
return { return {
drawer: false, drawer: false,
tabs: [
{ href: "summary", text: "Summary" },
{ href: "lines", text: "Lines" },
{ href: "plan", text: "Plan" },
{ href: "sequences", text: "Sequences" },
{ href: "calendar", text: "Calendar" },
{ href: "log", text: "Log" },
{ href: "qc", text: "QC" },
{ href: "graphs", text: "Graphs" },
{ href: "map", text: "Map" }
],
path: [] path: []
}; };
}, },
computed: { computed: {
tab () {
return this.tabs.findIndex(t => t.href == this.$route.path.split(/\/+/)[3]); appBarExtension () {
return this.$route.matched
.filter(i => i.meta?.appBarExtension)
.map(i => i.meta.appBarExtension)
.pop()?.component;
}, },
...mapGetters(['user', 'loading']) ...mapGetters(['user', 'loading'])
@@ -131,9 +117,6 @@ export default {
}, },
methods: { methods: {
tabLink (href) {
return `/projects/${this.$route.params.project}/${href}`;
},
breadcrumbs () { breadcrumbs () {
this.path = this.$route.matched this.path = this.$route.matched

View File

@@ -16,7 +16,7 @@ import Log from '../views/Log.vue'
import QC from '../views/QC.vue' import QC from '../views/QC.vue'
import Graphs from '../views/Graphs.vue' import Graphs from '../views/Graphs.vue'
import Map from '../views/Map.vue' import Map from '../views/Map.vue'
import DougalAppBarExtensionProject from '../components/app-bar-extension-project'
Vue.use(VueRouter) Vue.use(VueRouter)
@@ -100,7 +100,10 @@ Vue.use(VueRouter)
text: (ctx) => ctx.$store.state.project.projectName || "…", text: (ctx) => ctx.$store.state.project.projectName || "…",
href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/` href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/`
} }
] ],
appBarExtension: {
component: DougalAppBarExtensionProject
}
}, },
children: [ children: [
{ {

View File

@@ -1,14 +1,27 @@
<template> <template>
<v-container fluid> <v-container fluid>
<v-data-table <v-data-table
:headers="headers" :headers="headers"
:items="items" :items="displayItems"
:options.sync="options" :options.sync="options"
:loading="loading" :loading="loading"
> >
<template v-slot:item.pid="{item, value}"> <template v-slot:item.pid="{item, value}">
<a :href="`/projects/${item.pid}`">{{value}}</a> <template v-if="item.archived">
<a class="secondary--text" title="This project has been archived" :href="`/projects/${item.pid}`">{{value}}</a>
<v-icon
class="ml-1 secondary--text"
small
title="This project has been archived"
>mdi-archive-outline</v-icon>
</template>
<template v-else>
<a :href="`/projects/${item.pid}`">{{value}}</a>
</template>
</template>
</template> </template>
<template v-slot:item.shots="{item}"> <template v-slot:item.shots="{item}">
@@ -27,6 +40,19 @@
/> />
</template> </template>
<template v-slot:footer.prepend>
<v-checkbox
class="mr-3"
v-model="showArchived"
dense
hide-details
title="Projects that have been marked as archived by an administrator no longer receive updates from external sources, such as the project's file repository or the navigation system, but they may still be interacted with via Dougal, including adding or editing log comments."
>
<template v-slot:label>
<span class="subtitle-2">Show archived projects</span>
</template>
</v-checkbox>
</template>
</v-data-table> </v-data-table>
@@ -79,11 +105,20 @@ export default {
], ],
items: [], items: [],
options: {}, options: {},
showArchived: true,
} }
}, },
computed: { computed: {
...mapGetters(['loading', 'serverEvent']) ...mapGetters(['loading', 'serverEvent'])
displayItems () {
return this.showArchived
? this.items
: this.items.filter(i => !i.archived);
},
}, },
watch: { watch: {

View File

@@ -25,7 +25,6 @@ async function list (projectId, opts = {}) {
WHERE WHERE
($1::numeric IS NULL OR sequence = $1) AND ($1::numeric IS NULL OR sequence = $1) AND
($2::numeric[] IS NULL OR sequence = ANY( $2 )) AND ($2::numeric[] IS NULL OR sequence = ANY( $2 )) AND
($3::timestamptz IS NULL OR date(tstamp) = $3) AND
($3::timestamptz IS NULL OR ($3::timestamptz IS NULL OR
(($4::timestamptz IS NULL AND date(tstamp) = $3) OR (($4::timestamptz IS NULL AND date(tstamp) = $3) OR
date(tstamp) BETWEEN SYMMETRIC $3 AND $4)) AND date(tstamp) BETWEEN SYMMETRIC $3 AND $4)) AND

View File

@@ -1,7 +1,16 @@
const { setSurvey, pool } = require('../connection'); const { setSurvey, pool } = require('../connection');
async function get () { async function get () {
const res = await pool.query("SELECT pid, name, schema, COALESCE(meta->'groups', '[]'::jsonb) AS groups FROM public.projects;"); const text = `
SELECT
pid,
name,
schema,
COALESCE(meta->'groups', '[]'::jsonb) AS groups,
COALESCE(meta->'archived', 'false'::jsonb) AS archived
FROM public.projects;
`;
const res = await pool.query(text);
return res.rows; return res.rows;
} }