This repository has been archived on 2024-02-08. You can view files and clone it, but cannot push or open issues or pull requests.
frontend/src/components/tasks/partials/relatedTasks.vue

381 lines
9.7 KiB
Vue

<template>
<div class="task-relations">
<x-button
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
@click="showNewRelationForm = !showNewRelationForm"
class="is-pulled-right add-task-relation-button"
:class="{'is-active': showNewRelationForm}"
v-tooltip="$t('task.relation.add')"
variant="secondary"
icon="plus"
:shadow="false"
/>
<transition-group name="fade">
<template v-if="editEnabled && showCreate">
<label class="label" key="label">
{{ $t('task.relation.new') }}
<transition name="fade">
<span class="is-inline-flex" v-if="taskRelationService.loading">
<span class="loader is-inline-block mr-2"></span>
{{ $t('misc.saving') }}
</span>
<span class="has-text-success" v-else-if="!taskRelationService.loading && saved">
{{ $t('misc.saved') }}
</span>
</transition>
</label>
<div class="field" key="field-search">
<Multiselect
:placeholder="$t('task.relation.searchPlaceholder')"
@search="findTasks"
:loading="taskService.loading"
:search-results="mappedFoundTasks"
label="title"
v-model="newTaskRelationTask"
:creatable="true"
:create-placeholder="$t('task.relation.createPlaceholder')"
@create="createAndRelateTask"
>
<template #searchResult="{option: task}">
<span v-if="typeof task !== 'string'" class="search-result">
<span
class="different-list"
v-if="task.listId !== listId"
>
<span
v-if="task.differentNamespace !== null"
v-tooltip="$t('task.relation.differentNamespace')">
{{ task.differentNamespace }} >
</span>
<span
v-if="task.differentList !== null"
v-tooltip="$t('task.relation.differentList')">
{{ task.differentList }} >
</span>
</span>
{{ task.title }}
</span>
<span class="search-result" v-else>
{{ task }}
</span>
</template>
</Multiselect>
</div>
<div class="field has-addons mb-4" key="field-kind">
<div class="control is-expanded">
<div class="select is-fullwidth has-defaults">
<select v-model="newTaskRelationKind">
<option value="unset">{{ $t('task.relation.select') }}</option>
<option :key="rk" :value="rk" v-for="rk in relationKinds">
{{ $tc(`task.relation.kinds.${rk}`, 1) }}
</option>
</select>
</div>
</div>
<div class="control">
<x-button @click="addTaskRelation()">{{ $t('task.relation.add') }}</x-button>
</div>
</div>
</template>
</transition-group>
<div :key="rts.kind" class="related-tasks" v-for="rts in mappedRelatedTasks">
<span class="title">{{ rts.title }}</span>
<div class="tasks">
<div :key="t.id" class="task" v-for="t in rts.tasks">
<router-link
:to="{ name: $route.name, params: { id: t.id } }"
:class="{ 'is-strikethrough': t.done}">
<span
class="different-list"
v-if="t.listId !== listId"
>
<span
v-if="t.differentNamespace !== null"
v-tooltip="$t('task.relation.differentNamespace')">
{{ t.differentNamespace }} >
</span>
<span
v-if="t.differentList !== null"
v-tooltip="$t('task.relation.differentList')">
{{ t.differentList }} >
</span>
</span>
{{ t.title }}
</router-link>
<a
@click="() => {showDeleteModal = true; relationToDelete = {relationKind: rts.kind, otherTaskId: t.id}}"
class="remove"
v-if="editEnabled">
<icon icon="trash-alt"/>
</a>
</div>
</div>
</div>
<p class="none" v-if="showNoRelationsNotice && Object.keys(relatedTasks).length === 0">
{{ $t('task.relation.noneYet') }}
</p>
<!-- Delete modal -->
<transition name="modal">
<modal
@close="showDeleteModal = false"
@submit="removeTaskRelation()"
v-if="showDeleteModal"
>
<template #header><span>{{ $t('task.relation.delete') }}</span></template>
<template #text>
<p>
{{ $t('task.relation.deleteText1') }}<br/>
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</p>
</template>
</modal>
</transition>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, shallowReactive, watch, computed, PropType} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import TaskService from '@/services/task'
import TaskModel from '@/models/task'
import TaskRelationService from '@/services/taskRelation'
import TaskRelationModel from '@/models/taskRelation'
import Multiselect from '@/components/input/multiselect.vue'
import { error } from '@/message'
const relationKinds = [
'subtask',
'parenttask',
'related',
'duplicates',
'blocking',
'blocked',
'precedes',
'follows',
'copiedfrom',
'copiedto',
] as const
type RelationKinds = typeof relationKinds[number]
type RelatedTasks = Record<RelationKinds, TaskModel[]>
const props = defineProps({
taskId: {
type: Number,
required: true,
},
initialRelatedTasks: {
type: Object as PropType<RelatedTasks>,
default: () => ({}),
},
showNoRelationsNotice: {
type: Boolean,
default: false,
},
listId: {
type: Number,
default: 0,
},
editEnabled: {
default: true,
},
})
const relatedTasks = ref<RelatedTasks>({})
const taskService = shallowReactive(new TaskService())
const foundTasks = ref([])
const newTaskRelationTask = reactive<TaskModel>(new TaskModel())
const newTaskRelationKind = ref('related')
const taskRelationService = shallowReactive(new TaskRelationService())
const showDeleteModal = ref(false)
const relationToDelete = ref({})
const saved = ref(false)
const showNewRelationForm = ref(false)
const query = ref('')
watch(
() => props.initialRelatedTasks,
(value) => {
relatedTasks.value = value
},
{immediate: true},
)
const store = useStore()
const {t} = useI18n()
const showCreate = computed(() => Object.keys(relatedTasks.value).length === 0 || showNewRelationForm.value)
const namespace = computed(() => store.getters['namespaces/getListAndNamespaceById'](props.listId, true)?.namespace)
const mappedRelatedTasks = computed(() => Object.entries(relatedTasks.value).map(([kind, tasks]) => ({
title: t(`task.relation.kinds.${kind}`, tasks.length),
tasks: mapRelatedTasks(tasks),
kind,
})))
const mappedFoundTasks = computed(() => mapRelatedTasks(foundTasks.value.filter(t => t.id !== props.taskId)))
async function findTasks(newQuery: string) {
query.value = newQuery
foundTasks.value = await taskService.getAll({}, {s: newQuery})
}
async function addTaskRelation() {
if (newTaskRelationTask.id === 0 && query.value !== '') {
return createAndRelateTask(query.value)
}
if (newTaskRelationTask.id === 0) {
error({message: t('task.relation.taskRequired')})
return
}
await taskRelationService.create(new TaskRelationModel({
taskId: props.taskId,
otherTaskId: newTaskRelationTask.id,
relationKind: newTaskRelationKind.value,
}))
if (!relatedTasks.value[newTaskRelationKind.value]) {
relatedTasks.value[newTaskRelationKind.value] = []
}
relatedTasks.value[newTaskRelationKind.value].push(newTaskRelationTask)
Object.assign(newTaskRelationTask, new TaskModel({}))
saved.value = true
showNewRelationForm.value = false
setTimeout(() => {
saved.value = false
}, 2000)
}
async function removeTaskRelation() {
try {
const relationKind = relationToDelete.value.relationKind
await taskRelationService.delete(new TaskRelationModel({
relationKind,
taskId: props.taskId,
otherTaskId: relationToDelete.value.otherTaskId,
}))
for (const t in relatedTasks.value[relationKind]) {
if (relatedTasks.value[relationKind][t].id === relationToDelete.value.otherTaskId) {
relatedTasks.value[relationKind].splice(t, 1)
break
}
}
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
} finally {
showDeleteModal.value = false
}
}
async function createAndRelateTask(title: string) {
const newTask = await taskService.create(new TaskModel({title, listId: props.listId}))
Object.assign(newTaskRelationTask, newTask)
await addTaskRelation()
}
function mapRelatedTasks(tasks: TaskModel[]) {
return tasks.map(task => {
// by doing this here once we can save a lot of duplicate calls in the template
const listAndNamespace = store.getters['namespaces/getListAndNamespaceById'](task.listId, true)
const {
list,
namespace: taskNamespace,
} = listAndNamespace === null ? {list: null, namespace: null} : listAndNamespace
return {
...task,
differentNamespace:
(taskNamespace !== null &&
taskNamespace.id !== namespace.value.id &&
taskNamespace?.title) || null,
differentList:
(list !== null &&
task.listId !== props.listId &&
list?.title) || null,
}
})
}
</script>
<style lang="scss" scoped>
.add-task-relation-button {
margin-top: -3rem;
svg {
transition: transform $transition;
}
&.is-active svg {
transform: rotate(45deg);
}
}
.different-list {
color: var(--grey-500);
width: auto;
}
.title {
font-size: 1rem;
margin: 0;
}
.tasks {
padding: .5rem;
}
.task {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: .75rem;
transition: background-color $transition;
border-radius: $radius;
&:hover {
background-color: var(--grey-200);
}
a {
color: var(--text);
transition: color ease $transition-duration;
&:hover {
color: var(--grey-900);
}
}
.remove {
text-align: center;
color: var(--danger);
opacity: 0;
transition: opacity $transition;
}
}
.related-tasks:hover .tasks .task .remove {
opacity: 1;
}
.none {
font-style: italic;
text-align: center;
}
:deep(.multiselect .search-results button) {
padding: 0.5rem;
}
@include modal-transition();
</style>