mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 11:57:08 +00:00
384 lines
9.9 KiB
Vue
384 lines
9.9 KiB
Vue
<template>
|
|
<v-container fluid>
|
|
|
|
<v-data-table
|
|
:headers="headers"
|
|
:items="displayItems"
|
|
:options.sync="options"
|
|
:loading="loading"
|
|
@contextmenu:row="contextMenu"
|
|
>
|
|
|
|
<template v-slot:item.pid="{item, value}">
|
|
<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}`" :title="title(item)">{{value}}</a>
|
|
</template>
|
|
</template>
|
|
|
|
<template v-slot:item.groups="{item, value}">
|
|
<v-chip v-for="group in value"
|
|
label
|
|
small
|
|
>{{ group }}</v-chip>
|
|
</template>
|
|
|
|
<template v-slot:item.shots="{item}">
|
|
{{ item.total ? (item.prime + item.other) : "" }}
|
|
</template>
|
|
|
|
<template v-slot:item.progress="{item}">
|
|
{{
|
|
item.total
|
|
? ((1 - (item.remaining / item.total))*100).toFixed(1)+"%"
|
|
: ""
|
|
}}
|
|
<v-progress-linear v-if="item.total"
|
|
height="2"
|
|
:value="((1 - (item.remaining / item.total))*100)"
|
|
/>
|
|
</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-menu v-if="adminaccess(contextMenuItem)"
|
|
v-model="contextMenuShow"
|
|
:position-x="contextMenuX"
|
|
:position-y="contextMenuY"
|
|
absolute
|
|
offset-y
|
|
>
|
|
<v-list dense v-if="contextMenuItem">
|
|
<v-list-item :href="`${contextMenuItem.pid}/configuration`">
|
|
<v-list-item-icon><v-icon>mdi-file-document-edit-outline</v-icon></v-list-item-icon>
|
|
<v-list-item-title class="warning--text">Edit project settings</v-list-item-title>
|
|
</v-list-item>
|
|
<v-divider></v-divider>
|
|
<v-list-item @click="cloneDialogOpen = true">
|
|
<v-list-item-icon><v-icon>mdi-sheep</v-icon></v-list-item-icon>
|
|
<v-list-item-title class="warning--text">Clone project</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-menu>
|
|
|
|
<v-dialog
|
|
v-model="cloneDialogOpen"
|
|
max-width="600"
|
|
>
|
|
<dougal-project-settings-name-id-rootpath
|
|
v-model="cloneProjectDetails"
|
|
@input="cloneProject"
|
|
@close="cloneDialogOpen = false"
|
|
>
|
|
</dougal-project-settings-name-id-rootpath>
|
|
</v-dialog>
|
|
|
|
</v-container>
|
|
</template>
|
|
|
|
<style>
|
|
td p:last-of-type {
|
|
margin-bottom: 0;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
import { mapActions, mapGetters } from 'vuex';
|
|
import DougalProjectSettingsNameIdRootpath from '@/components/project-settings/name-id-rootpath'
|
|
import AccessMixin from '@/mixins/access';
|
|
|
|
export default {
|
|
name: "ProjectList",
|
|
|
|
components: {
|
|
DougalProjectSettingsNameIdRootpath
|
|
},
|
|
|
|
mixins: [
|
|
AccessMixin
|
|
],
|
|
|
|
data () {
|
|
return {
|
|
headers: [
|
|
{
|
|
value: "pid",
|
|
text: "Project ID"
|
|
},
|
|
{
|
|
value: "name",
|
|
text: "Name"
|
|
},
|
|
{
|
|
value: "groups",
|
|
text: "Groups"
|
|
},
|
|
{
|
|
value: "lines",
|
|
text: "Lines"
|
|
},
|
|
{
|
|
value: "seq_final",
|
|
text: "Sequences"
|
|
},
|
|
{
|
|
value: "total",
|
|
text: "Preplot points"
|
|
},
|
|
{
|
|
value: "shots",
|
|
text: "Shots acquired"
|
|
},
|
|
{
|
|
value: "progress",
|
|
text: "% Complete"
|
|
},
|
|
],
|
|
items: [],
|
|
options: { sortBy: ["pid"], sortDesc: [true] },
|
|
|
|
// Whether or not to show archived projects
|
|
showArchived: true,
|
|
|
|
// Cloned project stuff (admin only)
|
|
cloneDialogOpen: false,
|
|
cloneProjectDetails: {
|
|
name: null,
|
|
id: null,
|
|
path: null
|
|
},
|
|
|
|
// Context menu stuff
|
|
contextMenuShow: false,
|
|
contextMenuX: 0,
|
|
contextMenuY: 0,
|
|
contextMenuItem: null
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
displayItems () {
|
|
return this.showArchived
|
|
? this.items
|
|
: this.items.filter(i => !i.archived);
|
|
},
|
|
|
|
...mapGetters(['loading', 'serverEvent', 'projects'])
|
|
},
|
|
|
|
watch: {
|
|
async serverEvent (event) {
|
|
if (event.channel == "project" && event.payload?.schema == "public") {
|
|
if (event.payload?.operation == "DELETE" || event.payload?.operation == "INSERT") {
|
|
await this.load();
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
methods: {
|
|
|
|
async list () {
|
|
this.items = [...this.projects];
|
|
},
|
|
|
|
async summary (item) {
|
|
const details = await this.api([`/project/${item.pid}/summary`]);
|
|
if (details) {
|
|
return Object.assign({}, details, item);
|
|
}
|
|
},
|
|
|
|
title (item) {
|
|
if (item.organisations) {
|
|
return "Access:\n" + Object.entries(item.organisations).map( org =>
|
|
`• ${org[0]} (${Object.entries(org[1]).filter( access => access[1] ).map( access => access[0] ).join(", ")})`
|
|
).join("\n")
|
|
}
|
|
return "";
|
|
},
|
|
|
|
async load () {
|
|
await this.list();
|
|
const promises = [];
|
|
for (const key in this.items) {
|
|
const item = this.items[key];
|
|
const promise = this.summary(item)
|
|
.then( expanded => {
|
|
if (expanded) {
|
|
this.$set(this.items, key, expanded);
|
|
}
|
|
});
|
|
promises.push(promise);
|
|
}
|
|
},
|
|
|
|
contextMenu (e, {item}) {
|
|
e.preventDefault();
|
|
this.contextMenuShow = false;
|
|
this.contextMenuX = e.clientX;
|
|
this.contextMenuY = e.clientY;
|
|
this.contextMenuItem = item;
|
|
this.$nextTick( () => this.contextMenuShow = true );
|
|
},
|
|
|
|
|
|
async cloneProject () {
|
|
|
|
/* Plan of action:
|
|
* 1. Pop up dialogue asking for new project name, ID and root path
|
|
* 2. Get source project configuration
|
|
* 3. Blank out non-clonable parameters (ASAQC, …)
|
|
* 4. Rewrite paths prefixed with source rootPath with dest rootPath
|
|
* 5. Replace name, ID and rootPath
|
|
* 6. Set archived=true
|
|
* 7. POST new project
|
|
* 8. Redirect to new project settings page
|
|
*/
|
|
|
|
// 1. Pop up dialogue asking for new project name, ID and root path
|
|
// (already done, that's why we're here)
|
|
|
|
const pid = this.contextMenuItem?.pid;
|
|
|
|
if (!pid) return;
|
|
|
|
const tpl = this.cloneProjectDetails; // Shorter
|
|
|
|
if (!tpl.id || !tpl.name || !tpl.path) {
|
|
this.showSnack(["Missing project details. Cannot proceed", "warning"]);
|
|
return;
|
|
}
|
|
|
|
this.cloneDialogOpen = false;
|
|
|
|
/** Drills down an object and applies function fn(obj, key)
|
|
* on each recursive property [...keys].
|
|
*/
|
|
function drill (obj, keys, fn) {
|
|
if (obj) {
|
|
if (Array.isArray(keys)) {
|
|
if (keys.length) {
|
|
const key = keys.shift();
|
|
|
|
if (keys.length == 0 && key != "*") { // After shift()
|
|
if (typeof fn == "function") {
|
|
fn(obj, key);
|
|
}
|
|
}
|
|
|
|
if (key == "*") {
|
|
// Iterate through this object's keys
|
|
if (keys.length) {
|
|
for (const k in obj) {
|
|
drill(obj[k], [...keys], fn);
|
|
}
|
|
} else {
|
|
for (const k in obj) {
|
|
drill(obj, [k], fn);
|
|
}
|
|
}
|
|
} else {
|
|
drill(obj[key], keys, fn);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Get source project configuration
|
|
const src = await this.api([`/project/${pid}/configuration`]);
|
|
|
|
const blankList = [ "id", "name", "schema", "asaqc.id", "archived" ];
|
|
|
|
const prjPaths = [
|
|
"preplots.*.path",
|
|
"raw.p111.paths.*",
|
|
"raw.p190.paths.*",
|
|
"raw.smsrc.paths.*",
|
|
"final.p111.paths.*",
|
|
"final.p190.paths.*",
|
|
"qc.definitions",
|
|
"qc.parameters",
|
|
"imports.map.layers.*.*.path",
|
|
"rootPath" // Needs to go last because of lazy replacer() implementation below
|
|
];
|
|
|
|
// Technically don't need this
|
|
const deleter = (obj, key) => {
|
|
delete obj[key];
|
|
}
|
|
|
|
const replacer = (obj, key) => {
|
|
if (src.rootPath && tpl.path) {
|
|
if (obj[key].startsWith(src.rootPath)) {
|
|
obj[key] = obj[key].replace(src.rootPath, tpl.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Blank out non-clonable parameters (ASAQC, …)
|
|
blankList.forEach( i => drill(src, i.split("."), deleter) );
|
|
|
|
// 4. Rewrite paths prefixed with source rootPath with dest rootPath
|
|
prjPaths.forEach( i => drill(src, i.split("."), replacer) );
|
|
|
|
// 5. Replace name, ID and rootPath
|
|
// Could use deepMerge, but meh!
|
|
src.name = tpl.name;
|
|
src.id = tpl.id;
|
|
|
|
// 6. Set archived=true
|
|
src.archived = true;
|
|
|
|
// 7. POST new project
|
|
|
|
const init = {
|
|
method: "POST",
|
|
body: src
|
|
};
|
|
const cb = (err, res) => {
|
|
if (!err && res) {
|
|
if (res.status == "201") {
|
|
// 8. Redirect to new project settings page
|
|
const settingsUrl = `/projects/${tpl.id.toLowerCase()}/configuration`;
|
|
this.$router.push(settingsUrl);
|
|
|
|
}
|
|
}
|
|
};
|
|
await this.api(["/project", init, cb]);
|
|
|
|
},
|
|
|
|
...mapActions(["api", "showSnack"])
|
|
},
|
|
|
|
mounted () {
|
|
this.load();
|
|
}
|
|
}
|
|
|
|
</script>
|