Handle task relations the right way (#36)

This commit is contained in:
konrad 2019-10-28 21:45:37 +00:00
parent 7a997b52a6
commit 2705c1571e
7 changed files with 398 additions and 262 deletions

View file

@ -3,18 +3,23 @@
<div class="field"> <div class="field">
<label class="label" for="tasktext">Task Text</label> <label class="label" for="tasktext">Task Text</label>
<div class="control"> <div class="control">
<input v-focus :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="The task text is here..." v-model="taskEditTask.text"> <input v-focus :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input"
type="text" id="tasktext" placeholder="The task text is here..." v-model="taskEditTask.text">
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="taskdescription">Description</label> <label class="label" for="taskdescription">Description</label>
<div class="control"> <div class="control">
<textarea :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="textarea" placeholder="The tasks description goes here..." id="taskdescription" v-model="taskEditTask.description"></textarea> <textarea :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="textarea"
placeholder="The tasks description goes here..." id="taskdescription"
v-model="taskEditTask.description"></textarea>
</div> </div>
</div> </div>
<b>Reminder Dates</b> <b>Reminder Dates</b>
<div class="reminder-input" :class="{ 'overdue': (r < nowUnix && index !== (taskEditTask.reminderDates.length - 1))}" v-for="(r, index) in taskEditTask.reminderDates" :key="index"> <div class="reminder-input"
:class="{ 'overdue': (r < nowUnix && index !== (taskEditTask.reminderDates.length - 1))}"
v-for="(r, index) in taskEditTask.reminderDates" :key="index">
<flat-pickr <flat-pickr
:class="{ 'disabled': taskService.loading}" :class="{ 'disabled': taskService.loading}"
:disabled="taskService.loading" :disabled="taskService.loading"
@ -25,7 +30,9 @@
:data-index="index" :data-index="index"
placeholder="Add a new reminder..."> placeholder="Add a new reminder...">
</flat-pickr> </flat-pickr>
<a v-if="index !== (taskEditTask.reminderDates.length - 1)" @click="removeReminderByIndex(index)"><icon icon="times"></icon></a> <a v-if="index !== (taskEditTask.reminderDates.length - 1)" @click="removeReminderByIndex(index)">
<icon icon="times"></icon>
</a>
</div> </div>
<div class="field"> <div class="field">
@ -147,7 +154,9 @@
<ul class="assingees"> <ul class="assingees">
<li v-for="(a, index) in taskEditTask.assignees" :key="a.id"> <li v-for="(a, index) in taskEditTask.assignees" :key="a.id">
{{a.username}} {{a.username}}
<a @click="deleteAssigneeByIndex(index)"><icon icon="times"/></a> <a @click="deleteAssigneeByIndex(index)">
<icon icon="times"/>
</a>
</li> </li>
</ul> </ul>
</div> </div>
@ -166,7 +175,8 @@
label="username" label="username"
track-by="id"> track-by="id">
<template slot="clear" slot-scope="props"> <template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="newAssignee !== null && newAssignee.id !== 0" @mousedown.prevent.stop="clearAllFoundUsers(props.search)"></div> <div class="multiselect__clear" v-if="newAssignee !== null && newAssignee.id !== 0"
@mousedown.prevent.stop="clearAllFoundUsers(props.search)"></div>
</template> </template>
<span slot="noResult">Oops! No user found. Consider changing the search query.</span> <span slot="noResult">Oops! No user found. Consider changing the search query.</span>
</multiselect> </multiselect>
@ -204,45 +214,75 @@
tag-placeholder="Add this as new label" tag-placeholder="Add this as new label"
> >
<template slot="tag" slot-scope="{ option, remove }"> <template slot="tag" slot-scope="{ option, remove }">
<span class="tag" :style="{'background': option.hex_color, 'color': option.textColor}"> <span class="tag"
:style="{'background': option.hex_color, 'color': option.textColor}">
<span>{{ option.title }}</span> <span>{{ option.title }}</span>
<a class="delete is-small" @click="removeLabel(option)"></a> <a class="delete is-small" @click="removeLabel(option)"></a>
</span> </span>
</template> </template>
<template slot="clear" slot-scope="props"> <template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="taskEditTask.labels.length" @mousedown.prevent.stop="clearAllLabels(props.search)"></div> <div class="multiselect__clear" v-if="taskEditTask.labels.length"
@mousedown.prevent.stop="clearAllLabels(props.search)"></div>
</template> </template>
</multiselect> </multiselect>
</div> </div>
</div> </div>
<div class="field"> <div class="field" v-for="(rts, kind ) in task.related_tasks" :key="kind" v-if="rts.length > 0">
<label class="label" for="subtasks">Subtasks</label> <label class="label">{{ relationKinds[kind] }}</label>
<div class="tasks noborder" v-if="taskEditTask.subtasks && taskEditTask.subtasks.length > 0"> <div class="tasks noborder">
<div class="task" v-for="s in taskEditTask.subtasks" :key="s.id"> <div class="task" v-for="t in rts" :key="t.id">
<label :for="s.id"> <label>
<div class="fancycheckbox"> <span class="tasktext" :class="{ 'done': t.done}">
<input @change="markAsDone" type="checkbox" :id="s.id" :checked="s.done" style="display: none;"> {{t.text}}
<label :for="s.id" class="check"> </span>
<svg width="18px" height="18px" viewBox="0 0 18 18">
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
<polyline points="1 9 7 14 15 4"></polyline>
</svg>
</label>
</div>
<span class="tasktext" :class="{ 'done': s.done}">
{{s.text}}
</span>
</label> </label>
<a class="remove" @click="removeTaskRelation({relation_kind: kind, other_task_id: t.id})">
<icon icon="trash-alt"/>
</a>
</div> </div>
</div> </div>
</div> </div>
<div class="field has-addons">
<div class="control is-expanded"> <div class="field">
<input @keyup.enter="addSubtask()" :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="New subtask" v-model="newTask.text"/> <label class="label">New Task Relation</label>
<div class="field">
<div class="control is-expanded">
<multiselect
v-model="newTaskRelationTask"
:options="foundTasks"
:multiple="false"
:searchable="true"
:loading="taskService.loading"
:internal-search="true"
@search-change="findTasks"
placeholder="Type to search"
label="text"
track-by="id">
<template slot="clear" slot-scope="props">
<div class="multiselect__clear"
v-if="newTaskRelationTask !== null && newTaskRelationTask.id !== 0"
@mousedown.prevent.stop="clearAllFoundTasks(props.search)"></div>
</template>
<span slot="noResult">No task found. Consider changing the search query.</span>
</multiselect>
</div>
</div> </div>
<div class="control"> <div class="field has-addons">
<a class="button is-primary" @click="addSubtask()"><icon icon="plus"></icon></a> <div class="control is-expanded">
<div class="select is-fullwidth">
<select v-model="newTaskRelationKind">
<option value="unset">Select a kind of relation</option>
<option v-for="(label, rk) in relationKinds" :key="rk" :value="rk">
{{ label }}
</option>
</select>
</div>
</div>
<div class="control">
<a class="button is-primary" @click="addTaskRelation()">Add task Relation</a>
</div>
</div> </div>
</div> </div>
@ -254,231 +294,286 @@
</template> </template>
<script> <script>
import message from '../../message' import message from '../../message'
import flatPickr from 'vue-flatpickr-component' import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css' import 'flatpickr/dist/flatpickr.css'
import multiselect from 'vue-multiselect' import multiselect from 'vue-multiselect'
import {differenceWith} from 'lodash' import {differenceWith} from 'lodash'
import verte from 'verte' import verte from 'verte'
import 'verte/dist/verte.css' import 'verte/dist/verte.css'
import ListService from '../../services/list' import ListService from '../../services/list'
import TaskService from '../../services/task' import TaskService from '../../services/task'
import TaskModel from '../../models/task' import TaskModel from '../../models/task'
import UserModel from '../../models/user' import UserModel from '../../models/user'
import ListUserService from '../../services/listUsers' import ListUserService from '../../services/listUsers'
import priorities from '../../models/priorities' import priorities from '../../models/priorities'
import LabelTaskService from '../../services/labelTask' import LabelTaskService from '../../services/labelTask'
import LabelService from '../../services/label' import LabelService from '../../services/label'
import LabelTaskModel from '../../models/labelTask' import LabelTaskModel from '../../models/labelTask'
import LabelModel from '../../models/label' import LabelModel from '../../models/label'
import relationKinds from '../../models/relationKinds'
import TaskRelationModel from '../../models/taskRelation'
import TaskRelationService from '../../services/taskRelation'
export default { export default {
name: 'edit-task', name: 'edit-task',
data() { data() {
return { return {
listID: this.$route.params.id, listID: this.$route.params.id,
listService: ListService, listService: ListService,
taskService: TaskService, taskService: TaskService,
priorities: priorities, priorities: priorities,
list: {}, list: {},
newTask: TaskModel, newTask: TaskModel,
isTaskEdit: false, isTaskEdit: false,
taskEditTask: TaskModel, taskEditTask: TaskModel,
lastReminder: 0, lastReminder: 0,
nowUnix: new Date(), nowUnix: new Date(),
flatPickerConfig:{ flatPickerConfig: {
altFormat: 'j M Y H:i', altFormat: 'j M Y H:i',
altInput: true, altInput: true,
dateFormat: 'Y-m-d H:i', dateFormat: 'Y-m-d H:i',
enableTime: true, enableTime: true,
onOpen: this.updateLastReminderDate, onOpen: this.updateLastReminderDate,
onClose: this.addReminderDate, onClose: this.addReminderDate,
}, },
newAssignee: UserModel, newAssignee: UserModel,
listUserService: ListUserService, listUserService: ListUserService,
foundUsers: [], foundUsers: [],
labelService: LabelService, foundTasks: [],
labelTaskService: LabelTaskService, relationKinds: relationKinds,
foundLabels: [], newTaskRelationTask: TaskModel,
labelTimeout: null, newTaskRelationKind: 'unset',
} taskRelationService: TaskRelationService,
},
components: {
flatPickr,
multiselect,
verte,
},
props: {
task: {
type: TaskModel,
required: true,
}
},
watch: {
task() {
this.taskEditTask = this.task
}
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.newTask = new TaskModel()
this.listUserService = new ListUserService()
this.newAssignee = new UserModel()
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
this.taskEditTask = this.task
},
methods: {
editTaskSubmit() {
this.taskService.update(this.taskEditTask)
.then(r => {
this.$set(this, 'taskEditTask', r)
message.success({message: 'The task was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
addSubtask() {
this.newTask.parentTaskID = this.taskEditTask.id
this.newTask.listID = this.$route.params.id
this.taskService.create(this.newTask)
.then(r => {
this.list.addTaskToList(r)
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
message.error(e, this)
})
this.newTask = {} labelService: LabelService,
}, labelTaskService: LabelTaskService,
updateLastReminderDate(selectedDates) { foundLabels: [],
this.lastReminder = +new Date(selectedDates[0]) labelTimeout: null,
}, }
addReminderDate(selectedDates, dateStr, instance) { },
let newDate = +new Date(selectedDates[0]) components: {
flatPickr,
multiselect,
verte,
},
props: {
task: {
type: TaskModel,
required: true,
}
},
watch: {
task() {
this.taskEditTask = this.task
}
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.newTask = new TaskModel()
this.listUserService = new ListUserService()
this.newAssignee = new UserModel()
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
this.taskRelationService = new TaskRelationService()
this.newTaskRelationTask = new TaskModel()
this.taskEditTask = this.task
},
methods: {
editTaskSubmit() {
this.taskService.update(this.taskEditTask)
.then(r => {
this.$set(this, 'taskEditTask', r)
message.success({message: 'The task was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
updateLastReminderDate(selectedDates) {
this.lastReminder = +new Date(selectedDates[0])
},
addReminderDate(selectedDates, dateStr, instance) {
let newDate = +new Date(selectedDates[0])
// Don't update if nothing changed // Don't update if nothing changed
if (newDate === this.lastReminder) { if (newDate === this.lastReminder) {
return return
} }
let index = parseInt(instance.input.dataset.index) let index = parseInt(instance.input.dataset.index)
this.taskEditTask.reminderDates[index] = newDate this.taskEditTask.reminderDates[index] = newDate
let lastIndex = this.taskEditTask.reminderDates.length - 1 let lastIndex = this.taskEditTask.reminderDates.length - 1
// put a new null at the end if we changed something // put a new null at the end if we changed something
if (lastIndex === index && !isNaN(newDate)) { if (lastIndex === index && !isNaN(newDate)) {
this.taskEditTask.reminderDates.push(null) this.taskEditTask.reminderDates.push(null)
} }
}, },
removeReminderByIndex(index) { removeReminderByIndex(index) {
this.taskEditTask.reminderDates.splice(index, 1) this.taskEditTask.reminderDates.splice(index, 1)
// Reset the last to 0 to have the "add reminder" button // Reset the last to 0 to have the "add reminder" button
this.taskEditTask.reminderDates[this.taskEditTask.reminderDates.length - 1] = null this.taskEditTask.reminderDates[this.taskEditTask.reminderDates.length - 1] = null
}, },
addAssignee() { addAssignee() {
this.taskEditTask.assignees.push(this.newAssignee) this.taskEditTask.assignees.push(this.newAssignee)
}, },
deleteAssigneeByIndex(index) { deleteAssigneeByIndex(index) {
this.taskEditTask.assignees.splice(index, 1) this.taskEditTask.assignees.splice(index, 1)
}, },
findUser(query) { findUser(query) {
if(query === '') { if (query === '') {
this.clearAllFoundUsers() this.clearAllFoundUsers()
return return
} }
this.listUserService.getAll({listID: this.$route.params.id}, {s: query}) this.listUserService.getAll({listID: this.$route.params.id}, {s: query})
.then(response => { .then(response => {
// Filter the results to not include users who are already assigned // Filter the results to not include users who are already assigned
this.$set(this, 'foundUsers', differenceWith(response, this.taskEditTask.assignees, (first, second) => { this.$set(this, 'foundUsers', differenceWith(response, this.taskEditTask.assignees, (first, second) => {
return first.id === second.id return first.id === second.id
})) }))
}) })
.catch(e => { .catch(e => {
message.error(e, this) message.error(e, this)
}) })
}, },
clearAllFoundUsers () { clearAllFoundUsers() {
this.$set(this, 'foundUsers', []) this.$set(this, 'foundUsers', [])
}, },
findLabel(query) { findLabel(query) {
if(query === '') { if (query === '') {
this.clearAllLabels() this.clearAllLabels()
return return
} }
if(this.labelTimeout !== null) { if (this.labelTimeout !== null) {
clearTimeout(this.labelTimeout) clearTimeout(this.labelTimeout)
} }
// Delay the search 300ms to not send a request on every keystroke // Delay the search 300ms to not send a request on every keystroke
this.labelTimeout = setTimeout(() => { this.labelTimeout = setTimeout(() => {
this.labelService.getAll({}, {s: query}) this.labelService.getAll({}, {s: query})
.then(response => { .then(response => {
this.$set(this, 'foundLabels', differenceWith(response, this.taskEditTask.labels, (first, second) => { this.$set(this, 'foundLabels', differenceWith(response, this.taskEditTask.labels, (first, second) => {
return first.id === second.id return first.id === second.id
})) }))
this.labelTimeout = null this.labelTimeout = null
}) })
.catch(e => { .catch(e => {
message.error(e, this) message.error(e, this)
}) })
}, 300) }, 300)
}, },
clearAllLabels () { clearAllLabels() {
this.$set(this, 'foundLabels', []) this.$set(this, 'foundLabels', [])
}, },
addLabel(label) { addLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id}) let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id})
this.labelTaskService.create(labelTask) this.labelTaskService.create(labelTask)
.then(() => { .then(() => {
message.success({message: 'The label was successfully added.'}, this) message.success({message: 'The label was successfully added.'}, this)
}) })
.catch(e => { .catch(e => {
message.error(e, this) message.error(e, this)
}) })
}, },
removeLabel(label) { removeLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id}) let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id})
this.labelTaskService.delete(labelTask) this.labelTaskService.delete(labelTask)
.then(() => { .then(() => {
// Remove the label from the list // Remove the label from the list
for (const l in this.taskEditTask.labels) { for (const l in this.taskEditTask.labels) {
if (this.taskEditTask.labels[l].id === label.id) { if (this.taskEditTask.labels[l].id === label.id) {
this.taskEditTask.labels.splice(l, 1) this.taskEditTask.labels.splice(l, 1)
} }
} }
message.success({message: 'The label was successfully removed.'}, this) message.success({message: 'The label was successfully removed.'}, this)
}) })
.catch(e => { .catch(e => {
message.error(e, this) message.error(e, this)
}) })
}, },
createAndAddLabel(title) { createAndAddLabel(title) {
let newLabel = new LabelModel({title: title}) let newLabel = new LabelModel({title: title})
this.labelService.create(newLabel) this.labelService.create(newLabel)
.then(r => { .then(r => {
this.addLabel(r) this.addLabel(r)
this.taskEditTask.labels.push(r) this.taskEditTask.labels.push(r)
}) })
.catch(e => { .catch(e => {
message.error(e, this) message.error(e, this)
}) })
} },
}, findTasks(query) {
} if (query === '') {
this.clearAllFoundTasks()
return
}
this.taskService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundTasks', response)
})
.catch(e => {
message.error(e, this)
})
},
clearAllFoundTasks() {
this.$set(this, 'foundTasks', [])
},
addTaskRelation() {
let rel = new TaskRelationModel({
task_id: this.taskEditTask.id,
other_task_id: this.newTaskRelationTask.id,
relation_kind: this.newTaskRelationKind,
})
this.taskRelationService.create(rel)
.then(() => {
if (!this.taskEditTask.related_tasks[this.newTaskRelationKind]) {
this.$set(this.taskEditTask.related_tasks, this.newTaskRelationKind, [])
}
this.taskEditTask.related_tasks[this.newTaskRelationKind].push(this.newTaskRelationTask)
this.newTaskRelationKind = 'unset'
this.newTaskRelationTask = new TaskModel()
message.success({message: 'The task relation was created successfully'}, this)
})
.catch(e => {
message.error(e, this)
})
},
removeTaskRelation(relation) {
let rel = new TaskRelationModel({
relation_kind: relation.relation_kind,
task_id: this.taskEditTask.id,
other_task_id: relation.other_task_id,
})
this.taskRelationService.delete(rel)
.then(r => {
Object.keys(this.taskEditTask.related_tasks).forEach(relationKind => {
for (const t in this.taskEditTask.related_tasks[relationKind]) {
if (this.taskEditTask.related_tasks[relationKind][t].id === relation.other_task_id && relationKind === relation.relation_kind) {
this.taskEditTask.related_tasks[relationKind].splice(t, 1)
}
}
})
message.success(r, this)
})
.catch(e => {
message.error(e, this)
})
},
},
}
</script> </script>
<style scoped> <style scoped>
form { form {
margin-bottom: 1em; margin-bottom: 1em;
} }
</style> </style>

View file

@ -62,17 +62,7 @@ export default class ListModel extends AbstractModel {
* @param task * @param task
*/ */
addTaskToList(task) { addTaskToList(task) {
// If it's a subtask, add it to its parent, otherwise append it to the list of tasks this.tasks.push(task)
if (task.parentTaskID === 0) {
this.tasks.push(task)
} else {
for (const t in this.tasks) {
if (this.tasks[t].id === task.parentTaskID) {
this.tasks[t].subtasks.push(task)
break
}
}
}
this.sortTasks() this.sortTasks()
} }
@ -92,7 +82,7 @@ export default class ListModel extends AbstractModel {
} }
/** /**
* Loops through all tasks and updates the one with the id it has * Loops through all tasks and updates the one with the id it has
* @param task * @param task
*/ */
updateTaskByID(task) { updateTaskByID(task) {
@ -101,15 +91,6 @@ export default class ListModel extends AbstractModel {
this.tasks[t] = task this.tasks[t] = task
break break
} }
if (this.tasks[t].id === task.parentTaskID) {
for (const s in this.tasks[t].subtasks) {
if (this.tasks[t].subtasks[s].id === task.id) {
this.tasks[t].subtasks[s] = task
break
}
}
}
} }
this.sortTasks() this.sortTasks()
} }

View file

@ -0,0 +1,13 @@
{
"subtask": "Subtask",
"parenttask": "Parent Task",
"related": "Related Task",
"duplicateof": "Duplicate Of",
"duplicates": "Duplicates",
"blocking": "Blocking",
"blocked": "Blocked By",
"precedes": "Preceds",
"follows": "Follows",
"copiedfrom": "Copied From",
"copiedto": "Copied To"
}

View file

@ -37,6 +37,13 @@ export default class TaskModel extends AbstractModel {
if (this.hexColor.substring(0, 1) !== '#') { if (this.hexColor.substring(0, 1) !== '#') {
this.hexColor = '#' + this.hexColor this.hexColor = '#' + this.hexColor
} }
// Make all subtasks to task models
Object.keys(this.related_tasks).forEach(relationKind => {
this.related_tasks[relationKind] = this.related_tasks[relationKind].map(t => {
return new TaskModel(t)
})
})
} }
defaults() { defaults() {
@ -54,10 +61,10 @@ export default class TaskModel extends AbstractModel {
endDate: 0, endDate: 0,
repeatAfter: 0, repeatAfter: 0,
reminderDates: [], reminderDates: [],
subtasks: [],
parentTaskID: 0, parentTaskID: 0,
hexColor: '', hexColor: '',
percentDone: 0, percentDone: 0,
related_tasks: {},
createdBy: UserModel, createdBy: UserModel,
created: 0, created: 0,

View file

@ -0,0 +1,21 @@
import AbstractModel from './abstractModel'
import UserModel from "./user";
export default class TaskRelationModel extends AbstractModel {
constructor(data) {
super(data)
this.created_by = new UserModel(this.created_by)
}
defaults() {
return {
id: 0,
other_task_id: 0,
task_id: 0,
relation_kind: '',
created_by: UserModel,
created: 0,
}
}
}

View file

@ -0,0 +1,15 @@
import AbstractService from "./abstractService";
import TaskRelationModel from '../models/taskRelation'
export default class TaskRelationService extends AbstractService {
constructor() {
super({
create: '/tasks/{task_id}/relations',
delete: '/tasks/{task_id}/relations',
})
}
modelFactory(data) {
return new TaskRelationModel(data)
}
}

View file

@ -68,6 +68,10 @@
} }
} }
.remove {
color: $red;
}
.high-priority{ .high-priority{
color: $red; color: $red;