1
0

Task Detail View (#37)

This commit is contained in:
konrad
2019-11-24 13:16:24 +00:00
parent e00f0046b5
commit 4e5d14d969
39 changed files with 2228 additions and 503 deletions

View File

@ -0,0 +1,192 @@
<template>
<div class="attachments">
<h3>
<span class="icon is-grey">
<icon icon="paperclip"/>
</span>
Attachments
<a
class="button is-primary is-outlined is-small noshadow"
@click="$refs.files.click()"
:disabled="attachmentService.loading">
<span class="icon is-small"><icon icon="cloud-upload-alt"/></span>
Upload attachment
</a>
</h3>
<input type="file" id="files" ref="files" multiple @change="uploadNewAttachment()" :disabled="attachmentService.loading"/>
<progress v-if="attachmentService.uploadProgress > 0" class="progress is-primary" :value="attachmentService.uploadProgress" max="100">{{ attachmentService.uploadProgress }}%</progress>
<table>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Date</th>
<th>Action</th>
</tr>
<tr class="attachment" v-for="a in attachments" :key="a.id">
<td>
{{ a.file.name }}
</td>
<td>{{ a.file.getHumanSize() }}</td>
<td>{{ a.file.mime }}</td>
<!-- FIXME: This needs a better solution-->
<td>{{ new Date(a.created) }}</td>
<td>
<div class="buttons has-addons">
<a class="button is-primary noshadow" @click="downloadAttachment(a)" v-tooltip="'Download this attachment'">
<span class="icon">
<icon icon="cloud-download-alt"/>
</span>
</a>
<a class="button is-danger noshadow" v-tooltip="'Delete this attachment'" @click="() => {attachmentToDelete = a; showDeleteModal = true}">
<span class="icon">
<icon icon="trash-alt"/>
</span>
</a>
</div>
</td>
</tr>
</table>
<!-- Dropzone -->
<div class="dropzone" :class="{ 'hidden': !showDropzone }">
<div class="drop-hint">
<div class="icon">
<icon icon="cloud-upload-alt"/>
</div>
<div class="hint">
Drop files here to upload
</div>
</div>
</div>
<!-- Delete modal -->
<modal
v-if="showDeleteModal"
@close="showDeleteModal = false"
v-on:submit="deleteAttachment()">
<span slot="header">Delete attachment</span>
<p slot="text">Are you sure you want to delete the attachment {{ attachmentToDelete.file.name }}?<br/>
<b>This CANNOT BE UNDONE!</b></p>
</modal>
</div>
</template>
<script>
import AttachmentService from '../../../services/attachment'
import AttachmentModel from '../../../models/attachment'
import message from '../../../message'
export default {
name: 'attachments',
data() {
return {
attachments: [],
attachmentService: AttachmentService,
showDropzone: false,
showDeleteModal: false,
attachmentToDelete: AttachmentModel,
}
},
props: {
taskID: {
required: true,
type: Number,
},
initialAttachments: {
type: Array,
}
},
created() {
this.attachmentService = new AttachmentService()
this.attachments = this.initialAttachments
},
mounted() {
document.addEventListener('dragenter', e => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
});
window.addEventListener('dragleave', e => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = false
});
document.addEventListener('dragover', e => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
});
document.addEventListener('drop', e => {
e.stopPropagation()
e.preventDefault()
let files = e.dataTransfer.files
this.uploadFiles(files)
this.showDropzone = false
})
},
watch: {
initialAttachments(newVal) {
this.attachments = newVal
},
},
methods: {
downloadAttachment(attachment) {
this.attachmentService.download(attachment)
},
uploadNewAttachment() {
if(this.$refs.files.files.length === 0) {
return
}
this.uploadFiles(this.$refs.files.files)
},
uploadFiles(files) {
const attachmentModel = new AttachmentModel({task_id: this.taskID})
this.attachmentService.create(attachmentModel, files)
.then(r => {
if(r.success !== null) {
r.success.forEach(a => {
message.success({message: 'Successfully uploaded ' + a.file.name}, this)
this.attachments.push(a)
})
}
if(r.errors !== null) {
r.errors.forEach(m => {
message.error(m, this)
})
}
})
.catch(e => {
message.error(e, this)
})
},
deleteAttachment() {
this.attachmentService.delete(this.attachmentToDelete)
.then(r => {
// Remove the file from the list
for (const a in this.attachments) {
if (this.attachments[a].id === this.attachmentToDelete.id) {
this.attachments.splice(a, 1)
}
}
message.success(r, this)
})
.catch(e => {
message.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
})
},
},
}
</script>

View File

@ -0,0 +1,135 @@
<template>
<multiselect
:multiple="true"
:close-on-select="false"
:clear-on-select="true"
:options-limit="300"
:hide-selected="true"
v-model="assignees"
:options="foundUsers"
:searchable="true"
:loading="listUserService.loading"
:internal-search="true"
@search-change="findUser"
@select="addAssignee"
placeholder="Type to assign a user..."
label="username"
track-by="id"
select-label="Assign this user"
:showNoOptions="false"
>
<template slot="tag" slot-scope="{ option, remove }">
<user :user="option" :show-username="false" :avatar-size="30"/>
<a @click="removeAssignee(option)" class="remove-assignee">
<icon icon="times"/>
</a>
</template>
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="newAssignee !== null && newAssignee.id !== 0"
@mousedown.prevent.stop="clearAllFoundUsers(props.search)"></div>
</template>
<span slot="noResult">No user found. Consider changing the search query.</span>
</multiselect>
</template>
<script>
import {differenceWith} from 'lodash'
import message from '../../../message'
import multiselect from 'vue-multiselect'
import UserModel from '../../../models/user'
import ListUserService from '../../../services/listUsers'
import TaskAssigneeService from '../../../services/taskAssignee'
import TaskAssigneeModel from '../../../models/taskAssignee'
import User from '../../global/user'
export default {
name: 'editAssignees',
components: {
User,
multiselect,
},
props: {
taskID: {
type: Number,
required: true,
},
listID: {
type: Number,
required: true,
},
initialAssignees: {
type: Array,
default: () => [],
}
},
data() {
return {
newAssignee: UserModel,
listUserService: ListUserService,
foundUsers: [],
assignees: [],
taskAssigneeService: TaskAssigneeService,
}
},
created() {
this.assignees = this.initialAssignees
this.listUserService = new ListUserService()
this.newAssignee = new UserModel()
this.taskAssigneeService = new TaskAssigneeService()
},
watch: {
initialAssignees(newVal) {
this.assignees = newVal
}
},
methods: {
addAssignee(user) {
const taskAssignee = new TaskAssigneeModel({user_id: user.id, task_id: this.taskID})
this.taskAssigneeService.create(taskAssignee)
.then(() => {
message.success({message: 'The user was successfully assigned.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
removeAssignee(user) {
const taskAssignee = new TaskAssigneeModel({user_id: user.id, task_id: this.taskID})
this.taskAssigneeService.delete(taskAssignee)
.then(() => {
// Remove the assignee from the list
for (const a in this.assignees) {
if (this.assignees[a].id === user.id) {
this.assignees.splice(a, 1)
}
}
message.success({message: 'The user was successfully unassigned.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
findUser(query) {
if (query === '') {
this.clearAllFoundUsers()
return
}
this.listUserService.getAll({listID: this.listID}, {s: query})
.then(response => {
// Filter the results to not include users who are already assigned
this.$set(this, 'foundUsers', differenceWith(response, this.assignees, (first, second) => {
return first.id === second.id
}))
})
.catch(e => {
message.error(e, this)
})
},
clearAllFoundUsers() {
this.$set(this, 'foundUsers', [])
},
},
}
</script>

View File

@ -0,0 +1,155 @@
<template>
<multiselect
:multiple="true"
:close-on-select="false"
:clear-on-select="true"
:options-limit="300"
:hide-selected="true"
v-model="labels"
:options="foundLabels"
:searchable="true"
:loading="labelService.loading || labelTaskService.loading"
:internal-search="true"
@search-change="findLabel"
@select="addLabel"
placeholder="Type to add a new label..."
label="title"
track-by="id"
:taggable="true"
:showNoOptions="false"
@tag="createAndAddLabel"
tag-placeholder="Add this as new label"
>
<template slot="tag" slot-scope="{ option, remove }">
<span class="tag"
:style="{'background': option.hex_color, 'color': option.textColor}">
<span>{{ option.title }}</span>
<a class="delete is-small" @click="removeLabel(option)"></a>
</span>
</template>
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="labels.length"
@mousedown.prevent.stop="clearAllLabels(props.search)"></div>
</template>
</multiselect>
</template>
<script>
import message from '../../../message'
import { differenceWith } from 'lodash'
import multiselect from 'vue-multiselect'
import LabelService from '../../../services/label'
import LabelModel from '../../../models/label'
import LabelTaskService from '../../../services/labelTask'
import LabelTaskModel from '../../../models/labelTask'
export default {
name: 'edit-labels',
props: {
startLabels: {
default: () => [],
type: Array,
},
taskID: {
type: Number,
required: true,
},
},
data() {
return {
labelService: LabelService,
labelTaskService: LabelTaskService,
foundLabels: [],
labelTimeout: null,
labels: [],
searchQuery: '',
}
},
components: {
multiselect,
},
watch: {
startLabels(newLabels) {
this.labels = newLabels
}
},
created() {
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
this.labels = this.startLabels
},
methods: {
findLabel(query) {
this.searchQuery = query
if (query === '') {
this.clearAllLabels()
return
}
if (this.labelTimeout !== null) {
clearTimeout(this.labelTimeout)
}
// Delay the search 300ms to not send a request on every keystroke
this.labelTimeout = setTimeout(() => {
this.labelService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundLabels', differenceWith(response, this.labels, (first, second) => {
return first.id === second.id
}))
this.labelTimeout = null
})
.catch(e => {
message.error(e, this)
})
}, 300)
},
clearAllLabels() {
this.$set(this, 'foundLabels', [])
},
addLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskID, label_id: label.id})
this.labelTaskService.create(labelTask)
.then(() => {
message.success({message: 'The label was successfully added.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
removeLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskID, label_id: label.id})
this.labelTaskService.delete(labelTask)
.then(() => {
// Remove the label from the list
for (const l in this.labels) {
if (this.labels[l].id === label.id) {
this.labels.splice(l, 1)
}
}
message.success({message: 'The label was successfully removed.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
createAndAddLabel(title) {
let newLabel = new LabelModel({title: title})
this.labelService.create(newLabel)
.then(r => {
this.addLabel(r)
this.labels.push(r)
})
.catch(e => {
message.error(e, this)
})
},
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,49 @@
<template>
<div class="select">
<select v-model.number="percentDone" @change="updateData">
<option value="0">0%</option>
<option value="0.1">10%</option>
<option value="0.2">20%</option>
<option value="0.3">30%</option>
<option value="0.4">40%</option>
<option value="0.5">50%</option>
<option value="0.6">60%</option>
<option value="0.7">70%</option>
<option value="0.8">80%</option>
<option value="0.9">90%</option>
<option value="1">100%</option>
</select>
</div>
</template>
<script>
export default {
name: 'percentDoneSelect',
data() {
return {
percentDone: 0,
}
},
props: {
value: {
default: 0,
type: Number,
}
},
watch: {
// Set the priority to the :value every time it changes from the outside
value(newVal) {
this.percentDone = newVal
},
},
mounted() {
this.percentDone = this.value
},
methods: {
updateData() {
this.$emit('input', this.percentDone)
this.$emit('change')
}
},
}
</script>

View File

@ -0,0 +1,32 @@
<template>
<span v-if="priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': priority === priorities.HIGH}">
<span class="icon">
<icon icon="exclamation"/>
</span>
<template v-if="priority === priorities.HIGH">High</template>
<template v-if="priority === priorities.URGENT">Urgent</template>
<template v-if="priority === priorities.DO_NOW">DO NOW</template>
<span class="icon" v-if="priority === priorities.DO_NOW">
<icon icon="exclamation"/>
</span>
</span>
</template>
<script>
import priorites from '../../../models/priorities'
export default {
name: 'priorityLabel',
data() {
return {
priorities: priorites,
}
},
props: {
priority: {
default: 0,
type: Number,
}
}
}
</script>

View File

@ -0,0 +1,47 @@
<template>
<div class="select">
<select v-model="priority" @change="updateData">
<option :value="priorities.UNSET">Unset</option>
<option :value="priorities.LOW">Low</option>
<option :value="priorities.MEDIUM">Medium</option>
<option :value="priorities.HIGH">High</option>
<option :value="priorities.URGENT">Urgent</option>
<option :value="priorities.DO_NOW">DO NOW</option>
</select>
</div>
</template>
<script>
import priorites from '../../../models/priorities'
export default {
name: 'prioritySelect',
data() {
return {
priorities: priorites,
priority: 0,
}
},
props: {
value: {
default: 0,
type: Number,
}
},
watch: {
// Set the priority to the :value every time it changes from the outside
value(newVal) {
this.priority = newVal
},
},
mounted() {
this.priority = this.value
},
methods: {
updateData() {
this.$emit('input', this.priority)
this.$emit('change')
}
},
}
</script>

View File

@ -0,0 +1,192 @@
<template>
<div class="task-relations">
<label class="label">New Task Relation</label>
<div class="columns">
<div class="column is-three-quarters">
<multiselect
v-model="newTaskRelationTask"
:options="foundTasks"
:multiple="false"
:searchable="true"
:loading="taskService.loading"
:internal-search="true"
@search-change="findTasks"
placeholder="Type search for a new task to add as related..."
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 class="column field has-addons">
<div class="control is-expanded">
<div class="select is-fullwidth has-defaults">
<select v-model="newTaskRelationKind">
<option value="unset">Select a relation kind</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 class="related-tasks" v-for="(rts, kind ) in relatedTasks" :key="kind" v-if="rts.length > 0">
<span class="title">{{ relationKinds[kind] }}</span>
<div class="tasks noborder">
<div class="task" v-for="t in rts" :key="t.id">
<router-link :to="{ name: 'taskDetailView', params: { id: t.id } }">
<span class="tasktext" :class="{ 'done': t.done}">
{{t.text}}
</span>
</router-link>
<a class="remove" @click="() => {showDeleteModal = true; relationToDelete = {relation_kind: kind, other_task_id: t.id}}">
<icon icon="trash-alt"/>
</a>
</div>
</div>
</div>
<p v-if="showNoRelationsNotice && Object.keys(relatedTasks).length === 0" class="none">No task relations yet.</p>
<!-- Delete modal -->
<modal
v-if="showDeleteModal"
@close="showDeleteModal = false"
@submit="removeTaskRelation()">
<span slot="header">Delete Task Relation</span>
<p slot="text">Are you sure you want to delete this task relation?<br/>
<b>This CANNOT BE UNDONE!</b></p>
</modal>
</div>
</template>
<script>
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import TaskRelationService from '../../../services/taskRelation'
import relationKinds from '../../../models/relationKinds'
import TaskRelationModel from '../../../models/taskRelation'
import multiselect from 'vue-multiselect'
import message from '../../../message'
export default {
name: 'relatedTasks',
data() {
return {
relatedTasks: {},
taskService: TaskService,
foundTasks: [],
relationKinds: relationKinds,
newTaskRelationTask: TaskModel,
newTaskRelationKind: 'unset',
taskRelationService: TaskRelationService,
showDeleteModal: false,
relationToDelete: {},
}
},
components: {
multiselect,
},
props: {
taskID: {
type: Number,
required: true,
},
initialRelatedTasks: {
type: Object,
default: () => {},
},
showNoRelationsNotice: {
type: Boolean,
default: false,
},
},
created() {
this.taskService = new TaskService()
this.taskRelationService = new TaskRelationService()
this.newTaskRelationTask = new TaskModel()
},
watch: {
initialRelatedTasks(newVal) {
this.relatedTasks = newVal
},
},
mounted() {
this.relatedTasks = this.initialRelatedTasks
},
methods: {
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.taskID,
other_task_id: this.newTaskRelationTask.id,
relation_kind: this.newTaskRelationKind,
})
this.taskRelationService.create(rel)
.then(() => {
if (!this.relatedTasks[this.newTaskRelationKind]) {
this.$set(this.relatedTasks, this.newTaskRelationKind, [])
}
this.relatedTasks[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() {
let rel = new TaskRelationModel({
relation_kind: this.relationToDelete.relation_kind,
task_id: this.taskID,
other_task_id: this.relationToDelete.other_task_id,
})
this.taskRelationService.delete(rel)
.then(r => {
Object.keys(this.relatedTasks).forEach(relationKind => {
for (const t in this.relatedTasks[relationKind]) {
if (this.relatedTasks[relationKind][t].id === this.relationToDelete.other_task_id && relationKind === this.relationToDelete.relation_kind) {
this.relatedTasks[relationKind].splice(t, 1)
}
}
})
message.success(r, this)
})
.catch(e => {
message.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
})
},
},
}
</script>

View File

@ -0,0 +1,96 @@
<template>
<div class="reminders">
<div class="reminder-input"
:class="{ 'overdue': (r < nowUnix && index !== (reminders.length - 1))}"
v-for="(r, index) in reminders" :key="index">
<flat-pickr
:v-model="reminders"
:config="flatPickerConfig"
:id="'taskreminderdate' + index"
:value="r"
:data-index="index"
placeholder="Add a new reminder..."
>
</flat-pickr>
<a v-if="index !== (reminders.length - 1)" @click="removeReminderByIndex(index)">
<icon icon="times"></icon>
</a>
</div>
</div>
</template>
<script>
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
export default {
name: 'reminders',
data() {
return {
reminders: [],
lastReminder: 0,
nowUnix: new Date(),
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
onOpen: this.updateLastReminderDate,
onClose: this.addReminderDate,
},
}
},
props: {
value: {
default: () => [],
type: Array,
}
},
components: {
flatPickr,
},
mounted() {
this.reminders = this.value
},
watch: {
value(newVal) {
this.reminders = newVal
},
},
methods: {
updateData() {
this.$emit('input', this.reminders)
this.$emit('change')
},
updateLastReminderDate(selectedDates) {
this.lastReminder = +new Date(selectedDates[0])
},
addReminderDate(selectedDates, dateStr, instance) {
let newDate = +new Date(selectedDates[0])
// Don't update if nothing changed
if (newDate === this.lastReminder) {
return
}
let index = parseInt(instance.input.dataset.index)
this.reminders[index] = newDate
let lastIndex = this.reminders.length - 1
// put a new null at the end if we changed something
if (lastIndex === index && !isNaN(newDate)) {
this.reminders.push(null)
}
this.updateData()
},
removeReminderByIndex(index) {
this.reminders.splice(index, 1)
// Reset the last to 0 to have the "add reminder" button
this.reminders[this.reminders.length - 1] = null
this.updateData()
},
},
}
</script>

View File

@ -0,0 +1,61 @@
<template>
<div class="control repeat-after-input columns">
<div class="column">
<p>
Each
</p>
</div>
<div class="column is-two-fifths">
<input class="input" placeholder="Specify an amount..." v-model="repeatAfter.amount" @change="updateData"/>
</div>
<div class="column is-two-fifths">
<div class="select">
<select v-model="repeatAfter.type" @change="updateData">
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="years">Years</option>
</select>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'repeatAfter',
data() {
return {
repeatAfter: {},
}
},
props: {
value: {
default: () => {},
required: true,
}
},
watch: {
value(newVal) {
this.repeatAfter = newVal
},
},
mounted() {
this.repeatAfter = this.value
},
methods: {
updateData() {
this.$emit('input', this.repeatAfter)
this.$emit('change')
}
},
}
</script>
<style scoped>
p {
padding-top: 6px;
}
</style>