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
kolaente 7a9aa7771b
All checks were successful
continuous-integration/drone/push Build is passing
fix(tasks): play pop sound directly and not from store
This solves two problems:

1. Previously, changing anything on a done task would play the pop sound all the time, because the store only knew the new done status was "done" and not if it was done previously already.
2. Safari will prevent playing a sound without user interaction. This means the user has to interact directly with the method playing the sound which was not the case when the sound was played from the store.

Resolves #3292
2023-09-04 20:14:43 +02:00

432 lines
12 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 d-print-none"
:class="{'is-active': showNewRelationForm}"
v-tooltip="$t('task.relation.add')"
variant="secondary"
icon="plus"
:shadow="false"
id="showRelatedTasksFormButton"
/>
<transition-group name="fade">
<template v-if="editEnabled && showCreate">
<label class="label" key="label">
{{ $t('task.relation.new') }}
<CustomTransition 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>
</CustomTransition>
</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="newTaskRelation.task"
:creatable="true"
:create-placeholder="$t('task.relation.createPlaceholder')"
@create="createAndRelateTask"
>
<template #searchResult="{option: task}">
<span
v-if="typeof task !== 'string'"
class="search-result"
:class="{'is-strikethrough': task.done}"
>
<span
class="different-project"
v-if="task.projectId !== projectId"
>
<span
v-if="task.differentProject !== null"
v-tooltip="$t('task.relation.differentProject')">
{{ task.differentProject }} >
</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="newTaskRelation.kind">
<option value="unset">{{ $t('task.relation.select') }}</option>
<option :key="`option_${rk}`" :value="rk" v-for="rk in RELATION_KINDS">
{{ $t(`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">
<div class="is-flex is-align-items-center">
<Fancycheckbox
class="task-done-checkbox"
v-model="t.done"
@update:model-value="toggleTaskDone(t)"
/>
<router-link
:to="{ name: route.name as string, params: { id: t.id } }"
:class="{ 'is-strikethrough': t.done}"
>
<span
class="different-project"
v-if="t.projectId !== projectId"
>
<span
v-if="t.differentProject !== null"
v-tooltip="$t('task.relation.differentProject')">
{{ t.differentProject }} >
</span>
</span>
{{ t.title }}
</router-link>
</div>
<BaseButton
v-if="editEnabled"
@click="setRelationToDelete({
relationKind: rts.kind,
otherTaskId: t.id
})"
class="remove"
>
<icon icon="trash-alt"/>
</BaseButton>
</div>
</div>
</div>
<p class="none" v-if="showNoRelationsNotice && Object.keys(relatedTasks).length === 0">
{{ $t('task.relation.noneYet') }}
</p>
<modal
:enabled="relationToDelete !== undefined"
@close="relationToDelete = undefined"
@submit="removeTaskRelation()"
>
<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>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, shallowReactive, watch, computed, type PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRoute} from 'vue-router'
import TaskService from '@/services/task'
import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import type {ITaskRelation} from '@/modelTypes/ITaskRelation'
import {RELATION_KINDS, RELATION_KIND, type IRelationKind} from '@/types/IRelationKind'
import TaskRelationService from '@/services/taskRelation'
import TaskRelationModel from '@/models/taskRelation'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {playPopSound} from '@/helpers/playPop'
const props = defineProps({
taskId: {
type: Number,
required: true,
},
initialRelatedTasks: {
type: Object as PropType<ITask['relatedTasks']>,
default: () => ({}),
},
showNoRelationsNotice: {
type: Boolean,
default: false,
},
projectId: {
type: Number,
default: 0,
},
editEnabled: {
default: true,
},
})
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const route = useRoute()
const {t} = useI18n({useScope: 'global'})
type TaskRelation = {kind: IRelationKind, task: ITask}
const taskService = shallowReactive(new TaskService())
const relatedTasks = ref<ITask['relatedTasks']>({})
const newTaskRelation: TaskRelation = reactive({
kind: RELATION_KIND.RELATED,
task: new TaskModel(),
})
watch(
() => props.initialRelatedTasks,
(value) => {
relatedTasks.value = value
},
{immediate: true},
)
const showNewRelationForm = ref(false)
const showCreate = computed(() => Object.keys(relatedTasks.value).length === 0 || showNewRelationForm.value)
const query = ref('')
const foundTasks = ref<ITask[]>([])
async function findTasks(newQuery: string) {
query.value = newQuery
foundTasks.value = await taskService.getAll({}, {s: newQuery})
}
function mapRelatedTasks(tasks: ITask[]) {
return tasks.map(task => {
// by doing this here once we can save a lot of duplicate calls in the template
const project = projectStore.projects[task.ProjectId]
return {
...task,
differentProject:
(project &&
task.projectId !== props.projectId &&
project?.title) || null,
}
})
}
const mapRelationKindsTitleGetter = computed(() => ({
'subtask': (count: number) => t('task.relation.kinds.subtask', count),
'parenttask': (count: number) => t('task.relation.kinds.parenttask', count),
'related': (count: number) => t('task.relation.kinds.related', count),
'duplicateof': (count: number) => t('task.relation.kinds.duplicateof', count),
'duplicates': (count: number) => t('task.relation.kinds.duplicates', count),
'blocking': (count: number) => t('task.relation.kinds.blocking', count),
'blocked': (count: number) => t('task.relation.kinds.blocked', count),
'precedes': (count: number) => t('task.relation.kinds.precedes', count),
'follows': (count: number) => t('task.relation.kinds.follows', count),
'copiedfrom': (count: number) => t('task.relation.kinds.copiedfrom', count),
'copiedto': (count: number) => t('task.relation.kinds.copiedto', count),
}))
const mappedRelatedTasks = computed(() => Object.entries(relatedTasks.value).map(
([kind, tasks]) => ({
title: mapRelationKindsTitleGetter.value[kind as IRelationKind](tasks.length),
tasks: mapRelatedTasks(tasks),
kind: kind as IRelationKind,
}),
))
const mappedFoundTasks = computed(() => mapRelatedTasks(foundTasks.value.filter(t => t.id !== props.taskId)))
const taskRelationService = shallowReactive(new TaskRelationService())
const saved = ref(false)
async function addTaskRelation() {
if (newTaskRelation.task.id === 0 && query.value !== '') {
return createAndRelateTask(query.value)
}
if (newTaskRelation.task.id === 0) {
error({message: t('task.relation.taskRequired')})
return
}
await taskRelationService.create(new TaskRelationModel({
taskId: props.taskId,
otherTaskId: newTaskRelation.task.id,
relationKind: newTaskRelation.kind,
}))
relatedTasks.value[newTaskRelation.kind] = [
...(relatedTasks.value[newTaskRelation.kind] || []),
newTaskRelation.task,
]
newTaskRelation.task = new TaskModel()
saved.value = true
showNewRelationForm.value = false
setTimeout(() => {
saved.value = false
}, 2000)
}
const relationToDelete = ref<Partial<ITaskRelation>>()
function setRelationToDelete(relation: Partial<ITaskRelation>) {
relationToDelete.value = relation
}
async function removeTaskRelation() {
const relation = relationToDelete.value
if (!relation || !relation.relationKind || !relation.otherTaskId) {
relationToDelete.value = undefined
return
}
try {
const relationKind = relation.relationKind
await taskRelationService.delete(new TaskRelationModel({
relationKind,
taskId: props.taskId,
otherTaskId: relation.otherTaskId,
}))
relatedTasks.value[relationKind] = relatedTasks.value[relationKind]?.filter(
({id}) => id !== relation.otherTaskId,
)
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
} finally {
relationToDelete.value = undefined
}
}
async function createAndRelateTask(title: string) {
const newTask = await taskService.create(new TaskModel({title, projectId: props.projectId}))
newTaskRelation.task = newTask
await addTaskRelation()
}
async function toggleTaskDone(task: ITask) {
await taskStore.update(task)
if (task.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
playPopSound()
}
// Find the task in the project and update it so that it is correctly strike through
Object.entries(relatedTasks.value).some(([kind, tasks]) => {
return (tasks as ITask[]).some((t, key) => {
const found = t.id === task.id
if (found) {
relatedTasks.value[kind as IRelationKind]![key] = task
}
return found
})
})
success({message: t('task.detail.updateSuccess')})
}
</script>
<style lang="scss" scoped>
.add-task-relation-button {
margin-top: -3rem;
svg {
transition: transform $transition;
}
&.is-active svg {
transform: rotate(45deg);
}
}
.different-project {
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;
}
.task:hover .remove {
opacity: 1;
}
.none {
font-style: italic;
text-align: center;
}
:deep(.multiselect .search-results button) {
padding: 0.5rem;
}
// FIXME: The height of the actual checkbox in the <Fancycheckbox/> component is too much resulting in a
// weired positioning of the checkbox. Setting the height here is a workaround until we fix the styling
// of the component.
.task-done-checkbox {
padding: 0;
height: 18px; // The exact height of the checkbox in the container
margin-right: .75rem;
}
</style>