chore: move frontend files
This commit is contained in:
277
frontend/src/components/tasks/GanttChart.vue
Normal file
277
frontend/src/components/tasks/GanttChart.vue
Normal file
@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<Loading
|
||||
v-if="props.isLoading && !ganttBars.length || dayjsLanguageLoading"
|
||||
class="gantt-container"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
ref="ganttContainer"
|
||||
class="gantt-container"
|
||||
>
|
||||
<GGanttChart
|
||||
:date-format="DAYJS_ISO_DATE_FORMAT"
|
||||
:chart-start="isoToKebabDate(filters.dateFrom)"
|
||||
:chart-end="isoToKebabDate(filters.dateTo)"
|
||||
precision="day"
|
||||
bar-start="startDate"
|
||||
bar-end="endDate"
|
||||
:grid="true"
|
||||
:width="ganttChartWidth"
|
||||
@dragendBar="updateGanttTask"
|
||||
@dblclickBar="openTask"
|
||||
>
|
||||
<template #timeunit="{value, date}">
|
||||
<div
|
||||
class="timeunit-wrapper"
|
||||
:class="{'today': dateIsToday(date)}"
|
||||
>
|
||||
<span>{{ value }}</span>
|
||||
<span class="weekday">
|
||||
{{ weekDayFromDate(date) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<GGanttRow
|
||||
v-for="(bar, k) in ganttBars"
|
||||
:key="k"
|
||||
label=""
|
||||
:bars="bar"
|
||||
/>
|
||||
</GGanttChart>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, toRefs, onActivated} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
import {getHexColor} from '@/models/task'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||
import {parseKebabDate} from '@/helpers/time/parseKebabDate'
|
||||
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
|
||||
|
||||
import {
|
||||
extendDayjs,
|
||||
GGanttChart,
|
||||
GGanttRow,
|
||||
type GanttBarObject,
|
||||
} from '@infectoone/vue-ganttastic'
|
||||
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
||||
import {useWeekDayFromDate} from '@/helpers/time/formatDate'
|
||||
import dayjs from 'dayjs'
|
||||
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
|
||||
|
||||
export interface GanttChartProps {
|
||||
isLoading: boolean,
|
||||
filters: GanttFilters,
|
||||
tasks: Map<ITask['id'], ITask>,
|
||||
defaultTaskStartDate: DateISO
|
||||
defaultTaskEndDate: DateISO
|
||||
}
|
||||
|
||||
const props = defineProps<GanttChartProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:task', task: ITaskPartialWithId): void
|
||||
}>()
|
||||
|
||||
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
const {tasks, filters} = toRefs(props)
|
||||
|
||||
// setup dayjs for vue-ganttastic
|
||||
// const dayjsLanguageLoading = ref(false)
|
||||
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
||||
extendDayjs()
|
||||
|
||||
const ganttContainer = ref(null)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const dateFromDate = computed(() => new Date(new Date(filters.value.dateFrom).setHours(0,0,0,0)))
|
||||
const dateToDate = computed(() => new Date(new Date(filters.value.dateTo).setHours(23,59,0,0)))
|
||||
|
||||
const DAY_WIDTH_PIXELS = 30
|
||||
const ganttChartWidth = computed(() => {
|
||||
|
||||
const ganttContainerReference = ganttContainer?.value
|
||||
const ganttContainerWidth = ganttContainerReference ? (ganttContainerReference['clientWidth'] ?? 0) : 0
|
||||
|
||||
const dateDiff = Math.floor((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||
const calculatedWidth = dateDiff * DAY_WIDTH_PIXELS
|
||||
|
||||
return (calculatedWidth > ganttContainerWidth) ? calculatedWidth + 'px' : '100%'
|
||||
|
||||
})
|
||||
|
||||
const ganttBars = ref<GanttBarObject[][]>([])
|
||||
|
||||
/**
|
||||
* Update ganttBars when tasks change
|
||||
*/
|
||||
watch(
|
||||
tasks,
|
||||
() => {
|
||||
ganttBars.value = []
|
||||
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
|
||||
},
|
||||
{deep: true, immediate: true},
|
||||
)
|
||||
|
||||
function transformTaskToGanttBar(t: ITask) {
|
||||
const black = 'var(--grey-800)'
|
||||
|
||||
const taskColor = getHexColor(t.hexColor)
|
||||
|
||||
let textColor = black
|
||||
let backgroundColor = 'var(--grey-100)'
|
||||
if(t.startDate) {
|
||||
backgroundColor = taskColor ?? ''
|
||||
if(typeof taskColor === 'undefined') {
|
||||
textColor = 'white'
|
||||
backgroundColor = 'var(--primary)'
|
||||
} else if(colorIsDark(taskColor)) {
|
||||
textColor = black
|
||||
} else {
|
||||
textColor = 'white'
|
||||
}
|
||||
}
|
||||
|
||||
return [{
|
||||
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : props.defaultTaskStartDate),
|
||||
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : props.defaultTaskEndDate),
|
||||
ganttBarConfig: {
|
||||
id: String(t.id),
|
||||
label: t.title,
|
||||
hasHandles: true,
|
||||
style: {
|
||||
color: textColor,
|
||||
backgroundColor,
|
||||
border: t.startDate ? '' : '2px dashed var(--grey-300)',
|
||||
'text-decoration': t.done ? 'line-through' : null,
|
||||
},
|
||||
},
|
||||
} as GanttBarObject]
|
||||
}
|
||||
|
||||
async function updateGanttTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
emit('update:task', {
|
||||
id: Number(e.bar.ganttBarConfig.id),
|
||||
startDate: new Date(parseKebabDate(e.bar.startDate).setHours(0,0,0,0)),
|
||||
endDate: new Date(parseKebabDate(e.bar.endDate).setHours(23,59,0,0)),
|
||||
})
|
||||
}
|
||||
|
||||
function openTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
router.push({
|
||||
name: 'task.detail',
|
||||
params: {id: e.bar.ganttBarConfig.id},
|
||||
state: {backdropView: router.currentRoute.value.fullPath},
|
||||
})
|
||||
}
|
||||
|
||||
const weekDayFromDate = useWeekDayFromDate()
|
||||
|
||||
const today = ref(new Date())
|
||||
onActivated(() => today.value = new Date())
|
||||
const dateIsToday = computed(() => (date: Date) => {
|
||||
return (
|
||||
date.getDate() === today.value.getDate() &&
|
||||
date.getMonth() === today.value.getMonth() &&
|
||||
date.getFullYear() === today.value.getFullYear()
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gantt-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
// Not scoped because we need to style the elements inside the gantt chart component
|
||||
.g-gantt-chart {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.g-gantt-row-label {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.g-upper-timeunit, .g-timeunit {
|
||||
background: var(--white) !important;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.g-upper-timeunit {
|
||||
font-weight: bold;
|
||||
border-right: 1px solid var(--grey-200);
|
||||
padding: .5rem 0;
|
||||
}
|
||||
|
||||
.g-timeunit .timeunit-wrapper {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 1rem !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
&.today {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: 5px 5px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.g-timeaxis {
|
||||
height: auto !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.g-gantt-row > .g-gantt-row-bars-container {
|
||||
border-bottom: none !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
.g-gantt-row:nth-child(odd) {
|
||||
background: hsla(var(--grey-100-hsl), .5);
|
||||
}
|
||||
|
||||
.g-gantt-bar {
|
||||
border-radius: $radius * 1.5;
|
||||
overflow: visible;
|
||||
font-size: .85rem;
|
||||
|
||||
&-handle-left,
|
||||
&-handle-right {
|
||||
width: 6px !important;
|
||||
height: 75% !important;
|
||||
opacity: .75 !important;
|
||||
border-radius: $radius !important;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
84
frontend/src/components/tasks/TaskForm.vue
Normal file
84
frontend/src/components/tasks/TaskForm.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<form
|
||||
class="add-new-task"
|
||||
@submit.prevent="createTask"
|
||||
>
|
||||
<CustomTransition name="width">
|
||||
<input
|
||||
v-if="newTaskFieldActive"
|
||||
ref="newTaskTitleField"
|
||||
v-model="newTaskTitle"
|
||||
class="input"
|
||||
type="text"
|
||||
@blur="hideCreateNewTask"
|
||||
@keyup.esc="newTaskFieldActive = false"
|
||||
>
|
||||
</CustomTransition>
|
||||
<x-button
|
||||
:shadow="false"
|
||||
icon="plus"
|
||||
@click="showCreateTaskOrCreate"
|
||||
>
|
||||
{{ $t('task.new') }}
|
||||
</x-button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {nextTick, ref} from 'vue'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'createTask', title: string): Promise<ITask>
|
||||
}>()
|
||||
|
||||
const newTaskFieldActive = ref(false)
|
||||
const newTaskTitleField = ref()
|
||||
const newTaskTitle = ref('')
|
||||
|
||||
function showCreateTaskOrCreate() {
|
||||
if (!newTaskFieldActive.value) {
|
||||
// Timeout to not send the form if the field isn't even shown
|
||||
setTimeout(() => {
|
||||
newTaskFieldActive.value = true
|
||||
nextTick(() => newTaskTitleField.value.focus())
|
||||
}, 100)
|
||||
} else {
|
||||
createTask()
|
||||
}
|
||||
}
|
||||
|
||||
function hideCreateNewTask() {
|
||||
if (newTaskTitle.value === '') {
|
||||
nextTick(() => (newTaskFieldActive.value = false))
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask() {
|
||||
if (!newTaskFieldActive.value) {
|
||||
return
|
||||
}
|
||||
await emit('createTask', newTaskTitle.value)
|
||||
newTaskTitle.value = ''
|
||||
hideCreateNewTask()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-new-task {
|
||||
padding: 1rem .7rem .4rem .7rem;
|
||||
display: flex;
|
||||
max-width: 450px;
|
||||
|
||||
.input {
|
||||
margin-right: .7rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: .68rem;
|
||||
}
|
||||
}
|
||||
</style>
|
270
frontend/src/components/tasks/add-task.vue
Normal file
270
frontend/src/components/tasks/add-task.vue
Normal file
@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div
|
||||
ref="taskAdd"
|
||||
class="task-add"
|
||||
>
|
||||
<div class="add-task__field field is-grouped">
|
||||
<p class="control has-icons-left has-icons-right is-expanded">
|
||||
<textarea
|
||||
ref="newTaskInput"
|
||||
v-model="newTaskTitle"
|
||||
v-focus
|
||||
class="add-task-textarea input"
|
||||
:class="{'textarea-empty': newTaskTitle === ''}"
|
||||
:placeholder="$t('project.list.addPlaceholder')"
|
||||
rows="1"
|
||||
@keyup="resetEmptyTitleError"
|
||||
@keydown.enter="handleEnter"
|
||||
/>
|
||||
<span class="icon is-small is-left">
|
||||
<icon icon="tasks" />
|
||||
</span>
|
||||
<QuickAddMagic :highlight-hint-icon="taskAddHovered" />
|
||||
</p>
|
||||
<p class="control">
|
||||
<x-button
|
||||
class="add-task-button"
|
||||
:disabled="newTaskTitle === '' || loading || undefined"
|
||||
icon="plus"
|
||||
:loading="loading"
|
||||
:aria-label="$t('project.list.add')"
|
||||
@click="addTask()"
|
||||
>
|
||||
<span class="button-text">
|
||||
{{ $t('project.list.add') }}
|
||||
</span>
|
||||
</x-button>
|
||||
</p>
|
||||
</div>
|
||||
<Expandable :open="errorMessage !== ''">
|
||||
<p
|
||||
v-if="errorMessage !== ''"
|
||||
class="pt-3 mt-0 help is-danger"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</Expandable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useElementHover} from '@vueuse/core'
|
||||
|
||||
import {RELATION_KIND} from '@/types/IRelationKind'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import Expandable from '@/components/base/Expandable.vue'
|
||||
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
|
||||
import {parseSubtasksViaIndention} from '@/helpers/parseSubtasksViaIndention'
|
||||
import TaskRelationService from '@/services/taskRelation'
|
||||
import TaskRelationModel from '@/models/taskRelation'
|
||||
import {getLabelsFromPrefix} from '@/modules/parseTaskText'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
|
||||
|
||||
const props = defineProps({
|
||||
defaultPosition: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['taskAdded'])
|
||||
|
||||
const newTaskTitle = ref('')
|
||||
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const authStore = useAuthStore()
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
// enable only if we don't have a modal
|
||||
// onStartTyping(() => {
|
||||
// if (newTaskInput.value === null || document.activeElement === newTaskInput.value) {
|
||||
// return
|
||||
// }
|
||||
// newTaskInput.value.focus()
|
||||
// })
|
||||
|
||||
const taskAdd = ref<HTMLTextAreaElement | null>(null)
|
||||
const taskAddHovered = useElementHover(taskAdd)
|
||||
|
||||
const errorMessage = ref('')
|
||||
|
||||
function resetEmptyTitleError(e: KeyboardEvent) {
|
||||
if (
|
||||
(e.which <= 90 && e.which >= 48 || e.which >= 96 && e.which <= 105)
|
||||
&& newTaskTitle.value !== ''
|
||||
) {
|
||||
errorMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const loading = computed(() => taskStore.isLoading)
|
||||
|
||||
async function addTask() {
|
||||
if (newTaskTitle.value === '') {
|
||||
errorMessage.value = t('project.create.addTitleRequired')
|
||||
return
|
||||
}
|
||||
errorMessage.value = ''
|
||||
|
||||
if (loading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskTitleBackup = newTaskTitle.value
|
||||
// This allows us to find the tasks with the title they had before being parsed
|
||||
// by quick add magic.
|
||||
const createdTasks: { [key: ITask['title']]: ITask } = {}
|
||||
const tasksToCreate = parseSubtasksViaIndention(newTaskTitle.value, authStore.settings.frontendSettings.quickAddMagicMode)
|
||||
|
||||
// We ensure all labels exist prior to passing them down to the create task method
|
||||
// In the store it will only ever see one task at a time so there's no way to reliably
|
||||
// check if a new label was created before (because everything happens async).
|
||||
const allLabels = tasksToCreate.map(({title}) => getLabelsFromPrefix(title, authStore.settings.frontendSettings.quickAddMagicMode) ?? [])
|
||||
await taskStore.ensureLabelsExist(allLabels.flat())
|
||||
|
||||
const newTasks = tasksToCreate.map(async ({title, project}) => {
|
||||
if (title === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// If the task has a project specified, make sure to use it
|
||||
let projectId = null
|
||||
if (project !== null) {
|
||||
projectId = await taskStore.findProjectId({project, projectId: 0})
|
||||
}
|
||||
|
||||
const task = await taskStore.createNewTask({
|
||||
title,
|
||||
projectId: projectId || authStore.settings.defaultProjectId,
|
||||
position: props.defaultPosition,
|
||||
})
|
||||
createdTasks[title] = task
|
||||
return task
|
||||
})
|
||||
|
||||
try {
|
||||
newTaskTitle.value = ''
|
||||
await Promise.all(newTasks)
|
||||
|
||||
const taskRelationService = new TaskRelationService()
|
||||
const allParentTasks = tasksToCreate.filter(t => t.parent !== null).map(t => t.parent)
|
||||
const relations = tasksToCreate.map(async t => {
|
||||
const createdTask = createdTasks[t.title]
|
||||
if (typeof createdTask === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const isParent = allParentTasks.includes(t.title)
|
||||
if (t.parent === null && !isParent) {
|
||||
emit('taskAdded', createdTask)
|
||||
return
|
||||
}
|
||||
|
||||
const createdParentTask = createdTasks[t.parent]
|
||||
if (typeof createdTask === 'undefined' || typeof createdParentTask === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const rel = await taskRelationService.create(new TaskRelationModel({
|
||||
taskId: createdTask.id,
|
||||
otherTaskId: createdParentTask.id,
|
||||
relationKind: RELATION_KIND.PARENTTASK,
|
||||
}))
|
||||
|
||||
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [{
|
||||
...createdParentTask,
|
||||
relatedTasks: {}, // To avoid endless references
|
||||
}]
|
||||
// we're only emitting here so that the relation shows up in the project
|
||||
emit('taskAdded', createdTask)
|
||||
|
||||
createdParentTask.relatedTasks[RELATION_KIND.SUBTASK] = [{
|
||||
...createdTask,
|
||||
relatedTasks: {}, // To avoid endless references
|
||||
}]
|
||||
emit('taskAdded', createdParentTask)
|
||||
|
||||
return rel
|
||||
})
|
||||
await Promise.all(relations)
|
||||
} catch (e) {
|
||||
newTaskTitle.value = taskTitleBackup
|
||||
if (e?.message === 'NO_PROJECT') {
|
||||
errorMessage.value = t('project.create.addProjectRequired')
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter(e: KeyboardEvent) {
|
||||
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
|
||||
// the new task(s). The vue event modifier don't allow this, hence this method.
|
||||
if (e.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
addTask()
|
||||
}
|
||||
|
||||
function focusTaskInput() {
|
||||
newTaskInput.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusTaskInput,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-add,
|
||||
// overwrite bulma styles
|
||||
.task-add .add-task__field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.add-task-button {
|
||||
height: 100% !important;
|
||||
|
||||
@media screen and (max-width: $mobile) {
|
||||
.button-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.icon) {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-task-textarea {
|
||||
transition: border-color $transition;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
// Adding this class when the textarea has no text prevents the textarea from wrapping the placeholder.
|
||||
.textarea-empty {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.control.has-icons-left .icon,
|
||||
.control.has-icons-right .icon {
|
||||
transition: all $transition;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
button.show-helper-text {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
94
frontend/src/components/tasks/partials/assigneeList.vue
Normal file
94
frontend/src/components/tasks/partials/assigneeList.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import User from '@/components/misc/user.vue'
|
||||
import {computed} from 'vue'
|
||||
|
||||
const {
|
||||
assignees,
|
||||
remove,
|
||||
disabled,
|
||||
avatarSize = 30,
|
||||
inline = false,
|
||||
} = defineProps<{
|
||||
assignees: IUser[],
|
||||
remove?: (user: IUser) => void,
|
||||
disabled?: boolean,
|
||||
avatarSize?: number,
|
||||
inline?: boolean,
|
||||
}>()
|
||||
|
||||
const hasDelete = computed(() => typeof remove !== 'undefined' && !disabled)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="assignees-list"
|
||||
:class="{'is-inline': inline}"
|
||||
>
|
||||
<span
|
||||
v-for="user in assignees"
|
||||
:key="user.id"
|
||||
class="assignee"
|
||||
>
|
||||
<User
|
||||
:key="'user'+user.id"
|
||||
:avatar-size="avatarSize"
|
||||
:show-username="false"
|
||||
:user="user"
|
||||
:class="{'m-2': hasDelete}"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="hasDelete"
|
||||
:key="'delete'+user.id"
|
||||
class="remove-assignee"
|
||||
@click="remove(user)"
|
||||
>
|
||||
<icon icon="times" />
|
||||
</BaseButton>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.assignees-list {
|
||||
display: flex;
|
||||
|
||||
&.is-inline :deep(.user) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
&:hover .assignee:not(:first-child) {
|
||||
margin-left: -1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.assignee {
|
||||
position: relative;
|
||||
transition: all $transition;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -1.5rem;
|
||||
}
|
||||
|
||||
:deep(.user img) {
|
||||
border: 2px solid var(--white);
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-assignee {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 2px;
|
||||
color: var(--danger);
|
||||
background: var(--white);
|
||||
padding: 0 4px;
|
||||
display: block;
|
||||
border-radius: 100%;
|
||||
font-size: .75rem;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
445
frontend/src/components/tasks/partials/attachments.vue
Normal file
445
frontend/src/components/tasks/partials/attachments.vue
Normal file
@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<div class="attachments">
|
||||
<h3>
|
||||
<span class="icon is-grey">
|
||||
<icon icon="paperclip" />
|
||||
</span>
|
||||
{{ $t('task.attachment.title') }}
|
||||
</h3>
|
||||
|
||||
<input
|
||||
v-if="editEnabled"
|
||||
id="files"
|
||||
ref="filesRef"
|
||||
:disabled="loading || undefined"
|
||||
multiple
|
||||
type="file"
|
||||
@change="uploadNewAttachment()"
|
||||
>
|
||||
|
||||
<ProgressBar
|
||||
v-if="attachmentService.uploadProgress > 0"
|
||||
:value="attachmentService.uploadProgress * 100"
|
||||
is-primary
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="attachments.length > 0"
|
||||
class="files"
|
||||
>
|
||||
<!-- FIXME: don't use a for element that wraps other links / buttons
|
||||
Instead: overlay element with button that is inside.
|
||||
-->
|
||||
<a
|
||||
v-for="a in attachments"
|
||||
:key="a.id"
|
||||
class="attachment"
|
||||
@click="viewOrDownload(a)"
|
||||
>
|
||||
<div class="filename">
|
||||
{{ a.file.name }}
|
||||
<span
|
||||
v-if="task.coverImageAttachmentId === a.id"
|
||||
class="is-task-cover"
|
||||
>
|
||||
{{ $t('task.attachment.usedAsCover') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info">
|
||||
<p class="attachment-info-meta">
|
||||
<i18n-t
|
||||
keypath="task.attachment.createdBy"
|
||||
scope="global"
|
||||
>
|
||||
<span v-tooltip="formatDateLong(a.created)">
|
||||
{{ formatDateSince(a.created) }}
|
||||
</span>
|
||||
<User
|
||||
:avatar-size="24"
|
||||
:user="a.createdBy"
|
||||
:is-inline="true"
|
||||
/>
|
||||
</i18n-t>
|
||||
<span>
|
||||
{{ getHumanSize(a.file.size) }}
|
||||
</span>
|
||||
<span v-if="a.file.mime">
|
||||
{{ a.file.mime }}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<BaseButton
|
||||
v-tooltip="$t('task.attachment.downloadTooltip')"
|
||||
class="attachment-info-meta-button"
|
||||
@click.prevent.stop="downloadAttachment(a)"
|
||||
>
|
||||
{{ $t('misc.download') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('task.attachment.copyUrlTooltip')"
|
||||
class="attachment-info-meta-button"
|
||||
@click.stop="copyUrl(a)"
|
||||
>
|
||||
{{ $t('task.attachment.copyUrl') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="editEnabled"
|
||||
v-tooltip="$t('task.attachment.deleteTooltip')"
|
||||
class="attachment-info-meta-button"
|
||||
@click.prevent.stop="setAttachmentToDelete(a)"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="editEnabled"
|
||||
class="attachment-info-meta-button"
|
||||
@click.prevent.stop="setCoverImage(task.coverImageAttachmentId === a.id ? null : a)"
|
||||
>
|
||||
{{
|
||||
task.coverImageAttachmentId === a.id
|
||||
? $t('task.attachment.unsetAsCover')
|
||||
: $t('task.attachment.setAsCover')
|
||||
}}
|
||||
</BaseButton>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
v-if="editEnabled"
|
||||
:disabled="loading"
|
||||
class="mb-4"
|
||||
icon="cloud-upload-alt"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
@click="filesRef?.click()"
|
||||
>
|
||||
{{ $t('task.attachment.upload') }}
|
||||
</x-button>
|
||||
|
||||
<!-- Dropzone -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="editEnabled"
|
||||
:class="{ hidden: !isOverDropZone }"
|
||||
class="dropzone"
|
||||
>
|
||||
<div class="drop-hint">
|
||||
<div class="icon">
|
||||
<icon icon="cloud-upload-alt" />
|
||||
</div>
|
||||
<div class="hint">
|
||||
{{ $t('task.attachment.drop') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete modal -->
|
||||
<modal
|
||||
:enabled="attachmentToDelete !== null"
|
||||
@close="setAttachmentToDelete(null)"
|
||||
@submit="deleteAttachment()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('task.attachment.delete') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br>
|
||||
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
|
||||
<!-- Attachment image modal -->
|
||||
<modal
|
||||
:enabled="attachmentImageBlobUrl !== null"
|
||||
@close="attachmentImageBlobUrl = null"
|
||||
>
|
||||
<img
|
||||
:src="attachmentImageBlobUrl"
|
||||
alt=""
|
||||
>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, computed} from 'vue'
|
||||
import {useDropZone} from '@vueuse/core'
|
||||
|
||||
import User from '@/components/misc/user.vue'
|
||||
import ProgressBar from '@/components/misc/ProgressBar.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import AttachmentService from '@/services/attachment'
|
||||
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import {useAttachmentStore} from '@/stores/attachments'
|
||||
import {formatDateSince, formatDateLong} from '@/helpers/time/formatDate'
|
||||
import {uploadFiles, generateAttachmentUrl} from '@/helpers/attachments'
|
||||
import {getHumanSize} from '@/helpers/getHumanSize'
|
||||
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
||||
import {error, success} from '@/message'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const {
|
||||
task,
|
||||
editEnabled = true,
|
||||
} = defineProps<{
|
||||
task: ITask,
|
||||
editEnabled: boolean,
|
||||
}>()
|
||||
// FIXME: this should go through the store
|
||||
const emit = defineEmits(['taskChanged'])
|
||||
const taskStore = useTaskStore()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const attachmentService = shallowReactive(new AttachmentService())
|
||||
|
||||
const attachmentStore = useAttachmentStore()
|
||||
const attachments = computed(() => attachmentStore.attachments)
|
||||
|
||||
const loading = computed(() => attachmentService.loading || taskStore.isLoading)
|
||||
|
||||
function onDrop(files: File[] | null) {
|
||||
if (files && files.length !== 0) {
|
||||
uploadFilesToTask(files)
|
||||
}
|
||||
}
|
||||
|
||||
const {isOverDropZone} = useDropZone(document, onDrop)
|
||||
|
||||
function downloadAttachment(attachment: IAttachment) {
|
||||
attachmentService.download(attachment)
|
||||
}
|
||||
|
||||
const filesRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function uploadNewAttachment() {
|
||||
const files = filesRef.value?.files
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadFilesToTask(files)
|
||||
}
|
||||
|
||||
function uploadFilesToTask(files: File[] | FileList) {
|
||||
uploadFiles(attachmentService, task.id, files)
|
||||
}
|
||||
|
||||
const attachmentToDelete = ref<IAttachment | null>(null)
|
||||
|
||||
function setAttachmentToDelete(attachment: IAttachment | null) {
|
||||
attachmentToDelete.value = attachment
|
||||
}
|
||||
|
||||
async function deleteAttachment() {
|
||||
if (attachmentToDelete.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await attachmentService.delete(attachmentToDelete.value)
|
||||
attachmentStore.removeById(attachmentToDelete.value.id)
|
||||
success(r)
|
||||
setAttachmentToDelete(null)
|
||||
} catch (e) {
|
||||
error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentImageBlobUrl = ref<string | null>(null)
|
||||
|
||||
async function viewOrDownload(attachment: IAttachment) {
|
||||
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
|
||||
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
|
||||
} else {
|
||||
downloadAttachment(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
const copy = useCopyToClipboard()
|
||||
|
||||
function copyUrl(attachment: IAttachment) {
|
||||
copy(generateAttachmentUrl(task.id, attachment.id))
|
||||
}
|
||||
|
||||
async function setCoverImage(attachment: IAttachment | null) {
|
||||
const updatedTask = await taskStore.setCoverImage(task, attachment)
|
||||
emit('taskChanged', updatedTask)
|
||||
success({message: t('task.attachment.successfullyChangedCoverImage')})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.attachments {
|
||||
input[type=file] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
margin-bottom: .5rem;
|
||||
display: block;
|
||||
transition: background-color $transition;
|
||||
border-radius: $radius;
|
||||
padding: .5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-200);
|
||||
}
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-weight: bold;
|
||||
margin-bottom: .25rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.info {
|
||||
color: var(--grey-500);
|
||||
font-size: .9rem;
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
|
||||
> span:not(:last-child):after,
|
||||
> button:not(:last-child):after {
|
||||
content: '·';
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
position: fixed;
|
||||
background: hsla(var(--grey-100-hsl), 0.8);
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
text-align: center;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
.icon {
|
||||
width: 100%;
|
||||
font-size: 5rem;
|
||||
height: auto;
|
||||
text-shadow: var(--shadow-md);
|
||||
animation: bounce 2s infinite;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: .5rem auto 2rem;
|
||||
border-radius: $radius;
|
||||
box-shadow: var(--shadow-md);
|
||||
background: var(--primary);
|
||||
padding: 1rem;
|
||||
color: $white; // Should always be white because of the background, regardless of the theme
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-info-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:deep(.user) {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
margin: 0 .5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $mobile) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
:deep(.user) {
|
||||
margin: .5rem 0;
|
||||
}
|
||||
|
||||
> span:not(:last-child):after,
|
||||
> button:not(:last-child):after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user .username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-info-meta-button {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
from,
|
||||
20%,
|
||||
53%,
|
||||
80%,
|
||||
to {
|
||||
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
40%,
|
||||
43% {
|
||||
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
transform: translate3d(0, -30px, 0);
|
||||
}
|
||||
|
||||
70% {
|
||||
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
transform: translate3d(0, -15px, 0);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: translate3d(0, -4px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.is-task-cover {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
padding: .25rem .35rem;
|
||||
border-radius: 4px;
|
||||
font-size: .75rem;
|
||||
}
|
||||
</style>
|
87
frontend/src/components/tasks/partials/checklist-summary.vue
Normal file
87
frontend/src/components/tasks/partials/checklist-summary.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="checklist.total > 0"
|
||||
class="checklist-summary"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
>
|
||||
<circle
|
||||
stroke-width="2"
|
||||
fill="transparent"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="5"
|
||||
/>
|
||||
<circle
|
||||
stroke-width="2"
|
||||
stroke-dasharray="31"
|
||||
:stroke-dashoffset="checklistCircleDone"
|
||||
stroke-linecap="round"
|
||||
fill="transparent"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="5"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, type PropType} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {getChecklistStatistics} from '@/helpers/checklistFromText'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
type: Object as PropType<ITask>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const checklist = computed(() => getChecklistStatistics(props.task.description))
|
||||
|
||||
const checklistCircleDone = computed(() => {
|
||||
const r = 5
|
||||
const c = Math.PI * (r * 2)
|
||||
|
||||
const progress = checklist.value.checked / checklist.value.total * 100
|
||||
|
||||
return ((100 - progress) / 100) * c
|
||||
})
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const label = computed(() => {
|
||||
return checklist.value.total === checklist.value.checked
|
||||
? t('task.checklistAllDone', checklist.value)
|
||||
: t('task.checklistTotal', checklist.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.checklist-summary {
|
||||
color: var(--grey-500);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-left: .5rem;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
transform: rotate(-90deg);
|
||||
transition: stroke-dashoffset 0.35s;
|
||||
margin-right: .25rem;
|
||||
}
|
||||
|
||||
circle {
|
||||
stroke: var(--grey-400);
|
||||
|
||||
&:last-child {
|
||||
stroke: var(--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
401
frontend/src/components/tasks/partials/comments.vue
Normal file
401
frontend/src/components/tasks/partials/comments.vue
Normal file
@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="enabled"
|
||||
class="content details"
|
||||
>
|
||||
<h3
|
||||
v-if="canWrite || comments.length > 0"
|
||||
:class="{'d-print-none': comments.length === 0}"
|
||||
>
|
||||
<span class="icon is-grey">
|
||||
<icon :icon="['far', 'comments']" />
|
||||
</span>
|
||||
{{ $t('task.comment.title') }}
|
||||
</h3>
|
||||
<div class="comments">
|
||||
<span
|
||||
v-if="taskCommentService.loading && saving === null && !creating"
|
||||
class="is-inline-flex is-align-items-center"
|
||||
>
|
||||
<span class="loader is-inline-block mr-2" />
|
||||
{{ $t('task.comment.loading') }}
|
||||
</span>
|
||||
<div
|
||||
v-for="c in comments"
|
||||
:key="c.id"
|
||||
class="media comment"
|
||||
>
|
||||
<figure class="media-left is-hidden-mobile">
|
||||
<img
|
||||
:src="getAvatarUrl(c.author, 48)"
|
||||
alt=""
|
||||
class="image is-avatar"
|
||||
height="48"
|
||||
width="48"
|
||||
>
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="comment-info">
|
||||
<img
|
||||
:src="getAvatarUrl(c.author, 20)"
|
||||
alt=""
|
||||
class="image is-avatar d-print-none"
|
||||
height="20"
|
||||
width="20"
|
||||
>
|
||||
<strong>{{ getDisplayName(c.author) }}</strong>
|
||||
<span
|
||||
v-tooltip="formatDateLong(c.created)"
|
||||
class="has-text-grey"
|
||||
>
|
||||
{{ formatDateSince(c.created) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="+new Date(c.created) !== +new Date(c.updated)"
|
||||
v-tooltip="formatDateLong(c.updated)"
|
||||
>
|
||||
· {{ $t('task.comment.edited', {date: formatDateSince(c.updated)}) }}
|
||||
</span>
|
||||
<CustomTransition name="fade">
|
||||
<span
|
||||
v-if="
|
||||
taskCommentService.loading &&
|
||||
saving === c.id
|
||||
"
|
||||
class="is-inline-flex"
|
||||
>
|
||||
<span class="loader is-inline-block mr-2" />
|
||||
{{ $t('misc.saving') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
!taskCommentService.loading &&
|
||||
saved === c.id
|
||||
"
|
||||
class="has-text-success"
|
||||
>
|
||||
{{ $t('misc.saved') }}
|
||||
</span>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
<Editor
|
||||
v-model="c.comment"
|
||||
:is-edit-enabled="canWrite && c.author.id === currentUserId"
|
||||
:upload-callback="attachmentUpload"
|
||||
:upload-enabled="true"
|
||||
:bottom-actions="actions[c.id]"
|
||||
:show-save="true"
|
||||
initial-mode="preview"
|
||||
@update:modelValue="
|
||||
() => {
|
||||
toggleEdit(c)
|
||||
editCommentWithDelay()
|
||||
}
|
||||
"
|
||||
@save="() => {
|
||||
toggleEdit(c)
|
||||
editComment()
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="canWrite"
|
||||
class="media comment d-print-none"
|
||||
>
|
||||
<figure class="media-left is-hidden-mobile">
|
||||
<img
|
||||
:src="userAvatar"
|
||||
alt=""
|
||||
class="image is-avatar"
|
||||
height="48"
|
||||
width="48"
|
||||
>
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="form">
|
||||
<CustomTransition name="fade">
|
||||
<span
|
||||
v-if="taskCommentService.loading && creating"
|
||||
class="is-inline-flex"
|
||||
>
|
||||
<span class="loader is-inline-block mr-2" />
|
||||
{{ $t('task.comment.creating') }}
|
||||
</span>
|
||||
</CustomTransition>
|
||||
<div class="field">
|
||||
<Editor
|
||||
v-if="editorActive"
|
||||
v-model="newComment.comment"
|
||||
:class="{
|
||||
'is-loading':
|
||||
taskCommentService.loading &&
|
||||
!isCommentEdit,
|
||||
}"
|
||||
:upload-callback="attachmentUpload"
|
||||
:upload-enabled="true"
|
||||
:placeholder="$t('task.comment.placeholder')"
|
||||
@save="addComment()"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<x-button
|
||||
:loading="taskCommentService.loading && !isCommentEdit"
|
||||
:disabled="newComment.comment === ''"
|
||||
@click="addComment()"
|
||||
>
|
||||
{{ $t('task.comment.comment') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="() => deleteComment(commentToDelete)"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('task.comment.delete') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('task.comment.deleteText1') }}<br>
|
||||
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, computed, shallowReactive, watch, nextTick} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
|
||||
import TaskCommentService from '@/services/taskComment'
|
||||
import TaskCommentModel from '@/models/taskComment'
|
||||
|
||||
import type {ITaskComment} from '@/modelTypes/ITaskComment'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import {uploadFile} from '@/helpers/attachments'
|
||||
import {success} from '@/message'
|
||||
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import {getAvatarUrl, getDisplayName} from '@/models/user'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
taskId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
canWrite: {
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const comments = ref<ITaskComment[]>([])
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const commentToDelete = reactive(new TaskCommentModel())
|
||||
|
||||
const isCommentEdit = ref(false)
|
||||
const commentEdit = reactive(new TaskCommentModel())
|
||||
|
||||
const newComment = reactive(new TaskCommentModel())
|
||||
|
||||
const saved = ref<ITask['id'] | null>(null)
|
||||
const saving = ref<ITask['id'] | null>(null)
|
||||
|
||||
const userAvatar = computed(() => getAvatarUrl(authStore.info, 48))
|
||||
const currentUserId = computed(() => authStore.info.id)
|
||||
const enabled = computed(() => configStore.taskCommentsEnabled)
|
||||
const actions = computed(() => {
|
||||
if (!props.canWrite) {
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(comments.value.map((comment) => ([
|
||||
comment.id,
|
||||
comment.author.id === currentUserId.value
|
||||
? [{
|
||||
action: () => toggleDelete(comment.id),
|
||||
title: t('misc.delete'),
|
||||
}]
|
||||
: [],
|
||||
])))
|
||||
})
|
||||
|
||||
async function attachmentUpload(files: File[] | FileList): (Promise<string[]>) {
|
||||
|
||||
const uploadPromises: Promise<string>[] = []
|
||||
|
||||
files.forEach((file: File) => {
|
||||
const promise = new Promise<string>((resolve) => {
|
||||
uploadFile(props.taskId, file, (uploadedFileUrl: string) => resolve(uploadedFileUrl))
|
||||
})
|
||||
|
||||
uploadPromises.push(promise)
|
||||
})
|
||||
|
||||
return await Promise.all(uploadPromises)
|
||||
}
|
||||
|
||||
const taskCommentService = shallowReactive(new TaskCommentService())
|
||||
|
||||
async function loadComments(taskId: ITask['id']) {
|
||||
if (!enabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
newComment.taskId = taskId
|
||||
commentEdit.taskId = taskId
|
||||
commentToDelete.taskId = taskId
|
||||
comments.value = await taskCommentService.getAll({taskId})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.taskId,
|
||||
loadComments,
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const editorActive = ref(true)
|
||||
const creating = ref(false)
|
||||
|
||||
async function addComment() {
|
||||
if (newComment.comment === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// This makes the editor trigger its mounted function again which makes it forget every input
|
||||
// it currently has in its textarea. This is a counter-hack to a hack inside of vue-easymde
|
||||
// which made it impossible to detect change from the outside. Therefore the component would
|
||||
// not update if new content from the outside was made available.
|
||||
// See https://github.com/NikulinIlya/vue-easymde/issues/3
|
||||
editorActive.value = false
|
||||
nextTick(() => (editorActive.value = true))
|
||||
creating.value = true
|
||||
|
||||
try {
|
||||
const comment = await taskCommentService.create(newComment)
|
||||
comments.value.push(comment)
|
||||
newComment.comment = ''
|
||||
success({message: t('task.comment.addedSuccess')})
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEdit(comment: ITaskComment) {
|
||||
isCommentEdit.value = !isCommentEdit.value
|
||||
Object.assign(commentEdit, comment)
|
||||
}
|
||||
|
||||
function toggleDelete(commentId: ITaskComment['id']) {
|
||||
showDeleteModal.value = !showDeleteModal.value
|
||||
commentToDelete.id = commentId
|
||||
}
|
||||
|
||||
const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
async function editCommentWithDelay() {
|
||||
if (changeTimeout.value !== null) {
|
||||
clearTimeout(changeTimeout.value)
|
||||
}
|
||||
|
||||
changeTimeout.value = setTimeout(async () => {
|
||||
await editComment()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function editComment() {
|
||||
if (commentEdit.comment === '') {
|
||||
return
|
||||
}
|
||||
|
||||
if (changeTimeout.value !== null) {
|
||||
clearTimeout(changeTimeout.value)
|
||||
}
|
||||
|
||||
saving.value = commentEdit.id
|
||||
|
||||
commentEdit.taskId = props.taskId
|
||||
try {
|
||||
const comment = await taskCommentService.update(commentEdit)
|
||||
for (const c in comments.value) {
|
||||
if (comments.value[c].id === commentEdit.id) {
|
||||
comments.value[c] = comment
|
||||
}
|
||||
}
|
||||
saved.value = commentEdit.id
|
||||
setTimeout(() => {
|
||||
saved.value = null
|
||||
}, 2000)
|
||||
} finally {
|
||||
isCommentEdit.value = false
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(commentToDelete: ITaskComment) {
|
||||
try {
|
||||
await taskCommentService.delete(commentToDelete)
|
||||
const index = comments.value.findIndex(({id}) => id === commentToDelete.id)
|
||||
comments.value.splice(index, 1)
|
||||
success({message: t('task.comment.deleteSuccess')})
|
||||
} finally {
|
||||
showDeleteModal.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.media-left {
|
||||
margin: 0 1rem !important;
|
||||
}
|
||||
|
||||
.comment-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
|
||||
img {
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding-right: 0;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
span {
|
||||
font-size: .75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image.is-avatar {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.media-content {
|
||||
width: calc(100% - 48px - 2rem);
|
||||
}
|
||||
</style>
|
74
frontend/src/components/tasks/partials/createdUpdated.vue
Normal file
74
frontend/src/components/tasks/partials/createdUpdated.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<p class="created">
|
||||
<time
|
||||
v-tooltip="formatDateLong(task.created)"
|
||||
:datetime="formatISO(task.created)"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="task.detail.created"
|
||||
scope="global"
|
||||
>
|
||||
<span>{{ formatDateSince(task.created) }}</span>
|
||||
{{ getDisplayName(task.createdBy) }}
|
||||
</i18n-t>
|
||||
</time>
|
||||
<template v-if="+new Date(task.created) !== +new Date(task.updated)">
|
||||
<br>
|
||||
<!-- Computed properties to show the actual date every time it gets updated -->
|
||||
<time
|
||||
v-tooltip="updatedFormatted"
|
||||
:datetime="formatISO(task.updated)"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="task.detail.updated"
|
||||
scope="global"
|
||||
>
|
||||
<span>{{ updatedSince }}</span>
|
||||
</i18n-t>
|
||||
</time>
|
||||
</template>
|
||||
<template v-if="task.done">
|
||||
<br>
|
||||
<time
|
||||
v-tooltip="doneFormatted"
|
||||
:datetime="formatISO(task.doneAt)"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="task.detail.doneAt"
|
||||
scope="global"
|
||||
>
|
||||
<span>{{ doneSince }}</span>
|
||||
</i18n-t>
|
||||
</time>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, toRefs, type PropType} from 'vue'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {formatISO, formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import {getDisplayName} from '@/models/user'
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
type: Object as PropType<ITask>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const {task} = toRefs(props)
|
||||
|
||||
const updatedSince = computed(() => formatDateSince(task.value.updated))
|
||||
const updatedFormatted = computed(() => formatDateLong(task.value.updated))
|
||||
const doneSince = computed(() => formatDateSince(task.value.doneAt))
|
||||
const doneFormatted = computed(() => formatDateLong(task.value.doneAt))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.created {
|
||||
font-size: .75rem;
|
||||
color: var(--grey-500);
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
18
frontend/src/components/tasks/partials/date-table-cell.vue
Normal file
18
frontend/src/components/tasks/partials/date-table-cell.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<td v-tooltip="+date === 0 ? '' : formatDateLong(date)">
|
||||
<time :datetime="date ? formatISO(date) : undefined">
|
||||
{{ +date === 0 ? '-' : formatDateSince(date) }}
|
||||
</time>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {formatISO, formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
|
||||
|
||||
defineProps({
|
||||
date: {
|
||||
type: Date,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
</script>
|
190
frontend/src/components/tasks/partials/defer-task.vue
Normal file
190
frontend/src/components/tasks/partials/defer-task.vue
Normal file
@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'is-loading': taskService.loading }"
|
||||
class="defer-task loading-container"
|
||||
>
|
||||
<label class="label">{{ $t('task.deferDueDate.title') }}</label>
|
||||
<div class="defer-days">
|
||||
<x-button
|
||||
:shadow="false"
|
||||
variant="secondary"
|
||||
@click.prevent.stop="() => deferDays(1)"
|
||||
>
|
||||
{{ $t('task.deferDueDate.1day') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
:shadow="false"
|
||||
variant="secondary"
|
||||
@click.prevent.stop="() => deferDays(3)"
|
||||
>
|
||||
{{ $t('task.deferDueDate.3days') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
:shadow="false"
|
||||
variant="secondary"
|
||||
@click.prevent.stop="() => deferDays(7)"
|
||||
>
|
||||
{{ $t('task.deferDueDate.1week') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<flat-pickr
|
||||
v-model="dueDate"
|
||||
:class="{ disabled: taskService.loading }"
|
||||
:config="flatPickerConfig"
|
||||
:disabled="taskService.loading || undefined"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
} = defineProps<{
|
||||
modelValue: ITask,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const task = ref<ITask>()
|
||||
|
||||
// We're saving the due date seperately to prevent null errors in very short periods where the task is null.
|
||||
const dueDate = ref<Date | null>()
|
||||
const lastValue = ref<Date | null>()
|
||||
const changeInterval = ref<ReturnType<typeof setInterval>>()
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
(value) => {
|
||||
task.value = { ...value }
|
||||
dueDate.value = value.dueDate
|
||||
lastValue.value = value.dueDate
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// Because we don't really have other ways of handling change since if we let flatpickr
|
||||
// change events trigger updates, it would trigger a flatpickr change event which would trigger
|
||||
// an update which would trigger a change event and so on...
|
||||
// This is either a bug in flatpickr or in the vue component of it.
|
||||
// To work around that, we're only updating if something changed and check each second and when closing the popup.
|
||||
if (changeInterval.value) {
|
||||
clearInterval(changeInterval.value)
|
||||
}
|
||||
|
||||
changeInterval.value = setInterval(updateDueDate, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (changeInterval.value) {
|
||||
clearInterval(changeInterval.value)
|
||||
}
|
||||
updateDueDate()
|
||||
})
|
||||
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
inline: true,
|
||||
locale: getFlatpickrLanguage(),
|
||||
}))
|
||||
|
||||
function deferDays(days: number) {
|
||||
dueDate.value = new Date(dueDate.value)
|
||||
const currentDate = new Date(dueDate.value).getDate()
|
||||
dueDate.value = new Date(dueDate.value).setDate(currentDate + days)
|
||||
updateDueDate()
|
||||
}
|
||||
|
||||
async function updateDueDate() {
|
||||
if (!dueDate.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (+new Date(dueDate.value) === +lastValue.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const newTask = await taskService.update({
|
||||
...task.value,
|
||||
dueDate: new Date(dueDate.value),
|
||||
})
|
||||
lastValue.value = newTask.dueDate
|
||||
task.value = newTask
|
||||
emit('update:modelValue', newTask)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 100px is roughly the size the pane is pulled to the right
|
||||
$defer-task-max-width: 350px + 100px;
|
||||
|
||||
.defer-task {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
max-width: $defer-task-max-width;
|
||||
border-radius: $radius;
|
||||
border: 1px solid var(--grey-200);
|
||||
padding: 1rem;
|
||||
margin: 1rem;
|
||||
background: var(--white);
|
||||
color: var(--text);
|
||||
cursor: default;
|
||||
z-index: 10;
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
@media screen and (max-width: ($defer-task-max-width)) {
|
||||
left: .5rem;
|
||||
right: .5rem;
|
||||
max-width: 100%;
|
||||
width: calc(100vw - 1rem - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
.defer-days {
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
margin: .5rem 0;
|
||||
}
|
||||
|
||||
:deep() {
|
||||
input.input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flatpickr-calendar {
|
||||
margin: 0 auto;
|
||||
box-shadow: none;
|
||||
|
||||
@media screen and (max-width: ($defer-task-max-width)) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
span {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.flatpickr-innerContainer {
|
||||
@media screen and (max-width: ($defer-task-max-width)) {
|
||||
overflow: scroll;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
137
frontend/src/components/tasks/partials/description.vue
Normal file
137
frontend/src/components/tasks/partials/description.vue
Normal file
@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>
|
||||
<span class="icon is-grey">
|
||||
<icon icon="align-left" />
|
||||
</span>
|
||||
{{ $t('task.attributes.description') }}
|
||||
<CustomTransition name="fade">
|
||||
<span
|
||||
v-if="loading && saving"
|
||||
class="is-small is-inline-flex"
|
||||
>
|
||||
<span class="loader is-inline-block mr-2" />
|
||||
{{ $t('misc.saving') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="!loading && saved"
|
||||
class="is-small has-text-success"
|
||||
>
|
||||
<icon icon="check" />
|
||||
{{ $t('misc.saved') }}
|
||||
</span>
|
||||
</CustomTransition>
|
||||
</h3>
|
||||
<Editor
|
||||
v-model="description"
|
||||
class="tiptap__task-description"
|
||||
:is-edit-enabled="canWrite"
|
||||
:upload-callback="uploadCallback"
|
||||
:placeholder="$t('task.description.placeholder')"
|
||||
:show-save="true"
|
||||
edit-shortcut="e"
|
||||
@update:modelValue="saveWithDelay"
|
||||
@save="save"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
type AttachmentUploadFunction = (file: File, onSuccess: (attachmentUrl: string) => void) => Promise<string>
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
attachmentUpload,
|
||||
canWrite,
|
||||
} = defineProps<{
|
||||
modelValue: ITask,
|
||||
attachmentUpload: AttachmentUploadFunction,
|
||||
canWrite: boolean,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const description = ref<string>('')
|
||||
const saved = ref(false)
|
||||
|
||||
// Since loading is global state, this variable ensures we're only showing the saving icon when saving the description.
|
||||
const saving = ref(false)
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const loading = computed(() => taskStore.isLoading)
|
||||
|
||||
watch(
|
||||
() => modelValue.description,
|
||||
value => {
|
||||
description.value = value
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
async function saveWithDelay() {
|
||||
if (changeTimeout.value !== null) {
|
||||
clearTimeout(changeTimeout.value)
|
||||
}
|
||||
|
||||
changeTimeout.value = setTimeout(async () => {
|
||||
await save()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (changeTimeout.value !== null) {
|
||||
clearTimeout(changeTimeout.value)
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// FIXME: don't update state from internal.
|
||||
const updated = await taskStore.update({
|
||||
...modelValue,
|
||||
description: description.value,
|
||||
})
|
||||
emit('update:modelValue', updated)
|
||||
|
||||
saved.value = true
|
||||
setTimeout(() => {
|
||||
saved.value = false
|
||||
}, 2000)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCallback(files: File[] | FileList): (Promise<string[]>) {
|
||||
|
||||
const uploadPromises: Promise<string>[] = []
|
||||
|
||||
files.forEach((file: File) => {
|
||||
const promise = new Promise<string>((resolve) => {
|
||||
attachmentUpload(file, (uploadedFileUrl: string) => resolve(uploadedFileUrl))
|
||||
})
|
||||
|
||||
uploadPromises.push(promise)
|
||||
})
|
||||
|
||||
return await Promise.all(uploadPromises)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tiptap__task-description {
|
||||
// The exact amount of pixels we need to make the description icon align with the buttons and the form inside the editor.
|
||||
// The icon is not exactly the same length on all sides so we need to hack our way around it.
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
133
frontend/src/components/tasks/partials/editAssignees.vue
Normal file
133
frontend/src/components/tasks/partials/editAssignees.vue
Normal file
@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<Multiselect
|
||||
v-model="assignees"
|
||||
class="edit-assignees"
|
||||
:class="{'has-assignees': assignees.length > 0}"
|
||||
:loading="projectUserService.loading"
|
||||
:placeholder="$t('task.assignee.placeholder')"
|
||||
:multiple="true"
|
||||
:search-results="foundUsers"
|
||||
label="name"
|
||||
:select-placeholder="$t('task.assignee.selectPlaceholder')"
|
||||
:autocomplete-enabled="false"
|
||||
@search="findUser"
|
||||
@select="addAssignee"
|
||||
>
|
||||
<template #items="{items}">
|
||||
<AssigneeList
|
||||
:assignees="items"
|
||||
:remove="removeAssignee"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</template>
|
||||
<template #searchResult="{option: user}">
|
||||
<User
|
||||
:avatar-size="24"
|
||||
:show-username="true"
|
||||
:user="user"
|
||||
/>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, watch, nextTick, type PropType} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import User from '@/components/misc/user.vue'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import {includesById} from '@/helpers/utils'
|
||||
import ProjectUserService from '@/services/projectUsers'
|
||||
import {success} from '@/message'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import {getDisplayName} from '@/models/user'
|
||||
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
|
||||
|
||||
const props = defineProps({
|
||||
taskId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
projectId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as PropType<IUser[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const projectUserService = shallowReactive(new ProjectUserService())
|
||||
const foundUsers = ref<IUser[]>([])
|
||||
const assignees = ref<IUser[]>([])
|
||||
let isAdding = false
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
assignees.value = value
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
)
|
||||
|
||||
async function addAssignee(user: IUser) {
|
||||
if (isAdding) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
nextTick(() => isAdding = true)
|
||||
|
||||
await taskStore.addAssignee({user: user, taskId: props.taskId})
|
||||
emit('update:modelValue', assignees.value)
|
||||
success({message: t('task.assignee.assignSuccess')})
|
||||
} finally {
|
||||
nextTick(() => isAdding = false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAssignee(user: IUser) {
|
||||
await taskStore.removeAssignee({user: user, taskId: props.taskId})
|
||||
|
||||
// Remove the assignee from the project
|
||||
for (const a in assignees.value) {
|
||||
if (assignees.value[a].id === user.id) {
|
||||
assignees.value.splice(a, 1)
|
||||
}
|
||||
}
|
||||
success({message: t('task.assignee.unassignSuccess')})
|
||||
}
|
||||
|
||||
async function findUser(query: string) {
|
||||
const response = await projectUserService.getAll({projectId: props.projectId}, {s: query}) as IUser[]
|
||||
|
||||
// Filter the results to not include users who are already assigned
|
||||
foundUsers.value = response
|
||||
.filter(({id}) => !includesById(assignees.value, id))
|
||||
.map(u => {
|
||||
// Users may not have a display name set, so we fall back on the username in that case
|
||||
u.name = getDisplayName(u)
|
||||
return u
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.edit-assignees.has-assignees.multiselect .input {
|
||||
padding-left: 0;
|
||||
}
|
||||
</style>
|
169
frontend/src/components/tasks/partials/editLabels.vue
Normal file
169
frontend/src/components/tasks/partials/editLabels.vue
Normal file
@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<Multiselect
|
||||
v-model="labels"
|
||||
:loading="loading"
|
||||
:placeholder="$t('task.label.placeholder')"
|
||||
:multiple="true"
|
||||
:search-results="foundLabels"
|
||||
label="title"
|
||||
:creatable="creatable"
|
||||
:create-placeholder="$t('task.label.createPlaceholder')"
|
||||
:search-delay="10"
|
||||
:close-after-select="false"
|
||||
@search="findLabel"
|
||||
@select="addLabel"
|
||||
@create="createAndAddLabel"
|
||||
>
|
||||
<template #tag="{item: label}">
|
||||
<span
|
||||
:style="{'background': label.hexColor, 'color': label.textColor}"
|
||||
class="tag"
|
||||
>
|
||||
<span>{{ label.title }}</span>
|
||||
<BaseButton
|
||||
v-cy="'taskDetail.removeLabel'"
|
||||
class="delete is-small"
|
||||
@click="removeLabel(label)"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<template #searchResult="{option}">
|
||||
<span
|
||||
v-if="typeof option === 'string'"
|
||||
class="tag search-result"
|
||||
>
|
||||
<span>{{ option }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
:style="{'background': option.hexColor, 'color': option.textColor}"
|
||||
class="tag search-result"
|
||||
>
|
||||
<span>{{ option.title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {type PropType, ref, computed, shallowReactive, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import LabelModel from '@/models/label'
|
||||
import LabelTaskService from '@/services/labelTask'
|
||||
import {success} from '@/message'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import {getRandomColorHex} from '@/helpers/color/randomColor'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<ILabel[]>,
|
||||
default: () => [],
|
||||
},
|
||||
taskId: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
disabled: {
|
||||
default: false,
|
||||
},
|
||||
creatable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const labelTaskService = shallowReactive(new LabelTaskService())
|
||||
const labels = ref<ILabel[]>([])
|
||||
const query = ref('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
labels.value = value
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
)
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const labelStore = useLabelStore()
|
||||
|
||||
const foundLabels = computed(() => labelStore.filterLabelsByQuery(labels.value, query.value))
|
||||
const loading = computed(() => labelTaskService.loading || labelStore.isLoading)
|
||||
|
||||
function findLabel(newQuery: string) {
|
||||
query.value = newQuery
|
||||
}
|
||||
|
||||
async function addLabel(label: ILabel, showNotification = true) {
|
||||
if (props.taskId === 0) {
|
||||
emit('update:modelValue', labels.value)
|
||||
return
|
||||
}
|
||||
|
||||
await taskStore.addLabel({label, taskId: props.taskId})
|
||||
emit('update:modelValue', labels.value)
|
||||
if (showNotification) {
|
||||
success({message: t('task.label.addSuccess')})
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLabel(label: ILabel) {
|
||||
if (props.taskId !== 0) {
|
||||
await taskStore.removeLabel({label, taskId: props.taskId})
|
||||
}
|
||||
|
||||
for (const l in labels.value) {
|
||||
if (labels.value[l].id === label.id) {
|
||||
labels.value.splice(l, 1) // FIXME: l should be index
|
||||
}
|
||||
}
|
||||
emit('update:modelValue', labels.value)
|
||||
success({message: t('task.label.removeSuccess')})
|
||||
}
|
||||
|
||||
async function createAndAddLabel(title: string) {
|
||||
if (props.taskId === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const newLabel = await labelStore.createLabel(new LabelModel({
|
||||
title,
|
||||
hexColor: getRandomColorHex(),
|
||||
}))
|
||||
addLabel(newLabel, false)
|
||||
labels.value.push(newLabel)
|
||||
success({message: t('task.label.addCreateSuccess')})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag {
|
||||
margin: .25rem !important;
|
||||
}
|
||||
|
||||
.tag.search-result {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.input-wrapper) {
|
||||
padding: .25rem !important;
|
||||
}
|
||||
|
||||
:deep(input.input) {
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
</style>
|
166
frontend/src/components/tasks/partials/heading.vue
Normal file
166
frontend/src/components/tasks/partials/heading.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="heading">
|
||||
<div class="flex is-align-items-center">
|
||||
<BaseButton @click="copyUrl">
|
||||
<h1 class="title task-id">
|
||||
{{ textIdentifier }}
|
||||
</h1>
|
||||
</BaseButton>
|
||||
<Done
|
||||
class="heading__done"
|
||||
:is-done="task.done"
|
||||
/>
|
||||
<ColorBubble
|
||||
v-if="task.hexColor !== ''"
|
||||
:color="getHexColor(task.hexColor)"
|
||||
class="ml-2"
|
||||
/>
|
||||
</div>
|
||||
<h1
|
||||
class="title input"
|
||||
:class="{'disabled': !canWrite}"
|
||||
:contenteditable="canWrite ? true : undefined"
|
||||
:spellcheck="false"
|
||||
@blur="save(($event.target as HTMLInputElement).textContent as string)"
|
||||
@keydown.enter.prevent.stop="($event.target as HTMLInputElement).blur()"
|
||||
>
|
||||
{{ task.title.trim() }}
|
||||
</h1>
|
||||
<CustomTransition name="fade">
|
||||
<span
|
||||
v-if="loading && saving"
|
||||
class="is-inline-flex is-align-items-center"
|
||||
>
|
||||
<span class="loader is-inline-block mr-2" />
|
||||
{{ $t('misc.saving') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="!loading && showSavedMessage"
|
||||
class="has-text-success is-inline-flex is-align-content-center"
|
||||
>
|
||||
<icon
|
||||
icon="check"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ $t('misc.saved') }}
|
||||
</span>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, type PropType} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import Done from '@/components/misc/Done.vue'
|
||||
|
||||
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {getHexColor, getTaskIdentifier} from '@/models/task'
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
type: Object as PropType<ITask>,
|
||||
required: true,
|
||||
},
|
||||
canWrite: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:task'])
|
||||
|
||||
const router = useRouter()
|
||||
const copy = useCopyToClipboard()
|
||||
|
||||
async function copyUrl() {
|
||||
const route = router.resolve({name: 'task.detail', query: {taskId: props.task.id}})
|
||||
const absoluteURL = new URL(route.href, window.location.href).href
|
||||
|
||||
await copy(absoluteURL)
|
||||
}
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const loading = computed(() => taskStore.isLoading)
|
||||
|
||||
const textIdentifier = computed(() => getTaskIdentifier(props.task))
|
||||
|
||||
// Since loading is global state, this variable ensures we're only showing the saving icon when saving the description.
|
||||
const saving = ref(false)
|
||||
|
||||
const showSavedMessage = ref(false)
|
||||
|
||||
async function save(title: string) {
|
||||
// We only want to save if the title was actually changed.
|
||||
// so we only continue if the task title changed.
|
||||
if (title === props.task.title) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
const newTask = await taskStore.update({
|
||||
...props.task,
|
||||
title,
|
||||
})
|
||||
emit('update:task', newTask)
|
||||
showSavedMessage.value = true
|
||||
setTimeout(() => {
|
||||
showSavedMessage.value = false
|
||||
}, 2000)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
text-transform: none;
|
||||
align-items: center;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.title.input {
|
||||
// 1.8rem is the font-size, 1.125 is the line-height, .3rem padding everywhere, 1px border around the whole thing.
|
||||
min-height: calc(1.8rem * 1.125 + .6rem + 2px);
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin: 0 -.3rem .5rem -.3rem; // the title has 0.3rem padding - this make the text inside of it align with the rest
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) and (max-width: #{$desktop + $close-button-min-space}) {
|
||||
width: calc(100% - 6.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.title.task-id {
|
||||
color: var(--grey-400);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heading__done {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
.color-bubble {
|
||||
height: .75rem;
|
||||
width: .75rem;
|
||||
}
|
||||
</style>
|
354
frontend/src/components/tasks/partials/kanban-card.vue
Normal file
354
frontend/src/components/tasks/partials/kanban-card.vue
Normal file
@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div
|
||||
class="task loader-container draggable"
|
||||
:class="{
|
||||
'is-loading': loadingInternal || loading,
|
||||
'draggable': !(loadingInternal || loading),
|
||||
'has-light-text': !colorIsDark(color),
|
||||
'has-custom-background-color': color ?? undefined,
|
||||
}"
|
||||
:style="{'background-color': color ?? undefined}"
|
||||
@click.exact="openTaskDetail()"
|
||||
@click.ctrl="() => toggleTaskDone(task)"
|
||||
@click.meta="() => toggleTaskDone(task)"
|
||||
>
|
||||
<img
|
||||
v-if="coverImageBlobUrl"
|
||||
:src="coverImageBlobUrl"
|
||||
alt=""
|
||||
class="cover-image"
|
||||
>
|
||||
<div class="p-2">
|
||||
<span class="task-id">
|
||||
<Done
|
||||
class="kanban-card__done"
|
||||
:is-done="task.done"
|
||||
variant="small"
|
||||
/>
|
||||
<template v-if="task.identifier === ''">
|
||||
#{{ task.index }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ task.identifier }}
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="task.dueDate > 0"
|
||||
v-tooltip="formatDateLong(task.dueDate)"
|
||||
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
|
||||
class="due-date"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['far', 'calendar-alt']" />
|
||||
</span>
|
||||
<time :datetime="formatISO(task.dueDate)">
|
||||
{{ formatDateSince(task.dueDate) }}
|
||||
</time>
|
||||
</span>
|
||||
<h3>{{ task.title }}</h3>
|
||||
|
||||
<ProgressBar
|
||||
v-if="task.percentDone > 0"
|
||||
class="task-progress"
|
||||
:value="task.percentDone * 100"
|
||||
/>
|
||||
<div class="footer">
|
||||
<Labels :labels="task.labels" />
|
||||
<PriorityLabel
|
||||
:priority="task.priority"
|
||||
:done="task.done"
|
||||
class="is-inline-flex is-align-items-center"
|
||||
/>
|
||||
<AssigneeList
|
||||
v-if="task.assignees.length > 0"
|
||||
:assignees="task.assignees"
|
||||
:avatar-size="24"
|
||||
class="mr-1"
|
||||
/>
|
||||
<ChecklistSummary
|
||||
:task="task"
|
||||
class="checklist"
|
||||
/>
|
||||
<span
|
||||
v-if="task.attachments.length > 0"
|
||||
class="icon"
|
||||
>
|
||||
<icon icon="paperclip" />
|
||||
</span>
|
||||
<span
|
||||
v-if="!isEditorContentEmpty(task.description)"
|
||||
class="icon"
|
||||
>
|
||||
<icon icon="align-left" />
|
||||
</span>
|
||||
<span
|
||||
v-if="task.repeatAfter.amount > 0"
|
||||
class="icon"
|
||||
>
|
||||
<icon icon="history" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
|
||||
import ProgressBar from '@/components/misc/ProgressBar.vue'
|
||||
import Done from '@/components/misc/Done.vue'
|
||||
import Labels from '@/components/tasks/partials/labels.vue'
|
||||
import ChecklistSummary from './checklist-summary.vue'
|
||||
|
||||
import {getHexColor} from '@/models/task'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
|
||||
import AttachmentService from '@/services/attachment'
|
||||
|
||||
import {formatDateLong, formatISO, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {playPopSound} from '@/helpers/playPop'
|
||||
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||
|
||||
const {
|
||||
task,
|
||||
loading = false,
|
||||
} = defineProps<{
|
||||
task: ITask,
|
||||
loading: boolean,
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loadingInternal = ref(false)
|
||||
|
||||
const color = computed(() => getHexColor(task.hexColor))
|
||||
|
||||
async function toggleTaskDone(task: ITask) {
|
||||
loadingInternal.value = true
|
||||
try {
|
||||
const updatedTask = await useTaskStore().update({
|
||||
...task,
|
||||
done: !task.done,
|
||||
})
|
||||
|
||||
if (updatedTask.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
|
||||
playPopSound()
|
||||
}
|
||||
} finally {
|
||||
loadingInternal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTaskDetail() {
|
||||
router.push({
|
||||
name: 'task.detail',
|
||||
params: {id: task.id},
|
||||
state: {backdropView: router.currentRoute.value.fullPath},
|
||||
})
|
||||
}
|
||||
|
||||
const coverImageBlobUrl = ref<string | null>(null)
|
||||
|
||||
async function maybeDownloadCoverImage() {
|
||||
if (!task.coverImageAttachmentId) {
|
||||
coverImageBlobUrl.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const attachment = task.attachments.find(a => a.id === task.coverImageAttachmentId)
|
||||
if (!attachment || !SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
|
||||
return
|
||||
}
|
||||
|
||||
const attachmentService = new AttachmentService()
|
||||
coverImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => task.coverImageAttachmentId,
|
||||
maybeDownloadCoverImage,
|
||||
{immediate: true},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$task-background: var(--white);
|
||||
|
||||
.task {
|
||||
-webkit-touch-callout: none; // iOS Safari
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: block;
|
||||
|
||||
font-size: .9rem;
|
||||
border-radius: $radius;
|
||||
background: $task-background;
|
||||
overflow: hidden;
|
||||
|
||||
&.loader-container.is-loading::after {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
top: calc(50% - .75rem);
|
||||
left: calc(50% - .75rem);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: $family-sans-serif;
|
||||
font-size: .85rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
.due-date {
|
||||
float: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
margin-right: .25rem;
|
||||
}
|
||||
|
||||
&.overdue {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.label-wrapper .tag {
|
||||
margin: .5rem .5rem 0 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: .25rem;
|
||||
|
||||
:deep(.tag),
|
||||
:deep(.checklist-summary),
|
||||
.assignees,
|
||||
.icon,
|
||||
.priority-label {
|
||||
margin-right: .25rem;
|
||||
}
|
||||
|
||||
:deep(.checklist-summary) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.assignees {
|
||||
display: flex;
|
||||
|
||||
.user {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
|
||||
img {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: should be in labels.vue
|
||||
:deep(.tag) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.priority-label {
|
||||
font-size: .75rem;
|
||||
padding: 0 .5rem 0 .25rem;
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
padding: 0 .25rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer .icon,
|
||||
.due-date,
|
||||
.priority-label {
|
||||
background: var(--grey-100);
|
||||
border-radius: $radius;
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
color: var(--grey-500);
|
||||
font-size: .8rem;
|
||||
margin-bottom: .25rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&.is-moving {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
span {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&.has-custom-background-color {
|
||||
color: hsl(215, 27.9%, 16.9%); // copied from grey-800 to avoid different values in dark mode
|
||||
|
||||
.footer .icon,
|
||||
.due-date,
|
||||
.priority-label {
|
||||
background: hsl(220, 13%, 91%);
|
||||
}
|
||||
|
||||
.footer :deep(.checklist-summary) {
|
||||
color: hsl(216.9, 19.1%, 26.7%); // grey-700
|
||||
}
|
||||
}
|
||||
|
||||
&.has-light-text {
|
||||
--white: hsla(var(--white-h), var(--white-s), var(--white-l), var(--white-a)) !important;
|
||||
color: var(--white);
|
||||
|
||||
.task-id {
|
||||
color: hsl(220, 13%, 91%); // grey-200;
|
||||
}
|
||||
|
||||
.footer .icon,
|
||||
.due-date,
|
||||
.priority-label {
|
||||
background: hsl(215, 27.9%, 16.9%); // grey-800
|
||||
}
|
||||
|
||||
.footer {
|
||||
.icon svg {
|
||||
fill: var(--white);
|
||||
}
|
||||
|
||||
:deep(.checklist-summary) {
|
||||
color: hsl(220, 13%, 91%); // grey-200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kanban-card__done {
|
||||
margin-right: .25rem;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
margin: 8px 0 0 0;
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
}
|
||||
</style>
|
25
frontend/src/components/tasks/partials/label.vue
Normal file
25
frontend/src/components/tasks/partials/label.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
|
||||
defineProps<{
|
||||
label: ILabel
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:key="label.id"
|
||||
:style="{'background': label.hexColor, 'color': label.textColor}"
|
||||
class="tag"
|
||||
>
|
||||
<span>{{ label.title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tag {
|
||||
& + & {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
33
frontend/src/components/tasks/partials/labels.vue
Normal file
33
frontend/src/components/tasks/partials/labels.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="label-wrapper">
|
||||
<XLabel
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:label="label"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from 'vue'
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
|
||||
import XLabel from '@/components/tasks/partials/label.vue'
|
||||
|
||||
defineProps({
|
||||
labels: {
|
||||
type: Array as PropType<ILabel[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.label-wrapper {
|
||||
display: inline;
|
||||
|
||||
:deep(.tag) {
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
65
frontend/src/components/tasks/partials/percentDoneSelect.vue
Normal file
65
frontend/src/components/tasks/partials/percentDoneSelect.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="select">
|
||||
<select
|
||||
v-model.number="percentDone"
|
||||
:disabled="disabled || undefined"
|
||||
>
|
||||
<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 setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
default: 0,
|
||||
type: Number,
|
||||
},
|
||||
disabled: {
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const percentDone = computed({
|
||||
get: () => props.modelValue,
|
||||
set(percentDone) {
|
||||
emit('update:modelValue', percentDone)
|
||||
},
|
||||
})
|
||||
</script>
|
64
frontend/src/components/tasks/partials/priorityLabel.vue
Normal file
64
frontend/src/components/tasks/partials/priorityLabel.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="!done && (showAll || priority >= priorities.HIGH)"
|
||||
:class="{'not-so-high': priority === priorities.HIGH, 'high-priority': priority >= priorities.HIGH}"
|
||||
class="priority-label"
|
||||
>
|
||||
<span
|
||||
v-if="priority >= priorities.HIGH"
|
||||
class="icon"
|
||||
>
|
||||
<icon icon="exclamation" />
|
||||
</span>
|
||||
<span>
|
||||
<template v-if="priority === priorities.UNSET">{{ $t('task.priority.unset') }}</template>
|
||||
<template v-if="priority === priorities.LOW">{{ $t('task.priority.low') }}</template>
|
||||
<template v-if="priority === priorities.MEDIUM">{{ $t('task.priority.medium') }}</template>
|
||||
<template v-if="priority === priorities.HIGH">{{ $t('task.priority.high') }}</template>
|
||||
<template v-if="priority === priorities.URGENT">{{ $t('task.priority.urgent') }}</template>
|
||||
<template v-if="priority === priorities.DO_NOW">{{ $t('task.priority.doNow') }}</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="priority === priorities.DO_NOW"
|
||||
class="icon pr-0"
|
||||
>
|
||||
<icon icon="exclamation" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {PRIORITIES as priorities} from '@/constants/priorities'
|
||||
|
||||
defineProps({
|
||||
priority: {
|
||||
default: 0,
|
||||
type: Number,
|
||||
},
|
||||
showAll: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
done: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
span.high-priority {
|
||||
color: var(--danger);
|
||||
width: auto !important; // To override the width set in tasks
|
||||
|
||||
.icon {
|
||||
vertical-align: top;
|
||||
width: auto !important;
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
|
||||
&.not-so-high {
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
</style>
|
58
frontend/src/components/tasks/partials/prioritySelect.vue
Normal file
58
frontend/src/components/tasks/partials/prioritySelect.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="select">
|
||||
<select
|
||||
v-model="priority"
|
||||
:disabled="disabled || undefined"
|
||||
@change="updateData"
|
||||
>
|
||||
<option :value="PRIORITIES.UNSET">
|
||||
{{ $t('task.priority.unset') }}
|
||||
</option>
|
||||
<option :value="PRIORITIES.LOW">
|
||||
{{ $t('task.priority.low') }}
|
||||
</option>
|
||||
<option :value="PRIORITIES.MEDIUM">
|
||||
{{ $t('task.priority.medium') }}
|
||||
</option>
|
||||
<option :value="PRIORITIES.HIGH">
|
||||
{{ $t('task.priority.high') }}
|
||||
</option>
|
||||
<option :value="PRIORITIES.URGENT">
|
||||
{{ $t('task.priority.urgent') }}
|
||||
</option>
|
||||
<option :value="PRIORITIES.DO_NOW">
|
||||
{{ $t('task.priority.doNow') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch} from 'vue'
|
||||
import {PRIORITIES} from '@/constants/priorities'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
disabled: {
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const priority = ref(0)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
priority.value = value
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function updateData() {
|
||||
emit('update:modelValue', priority.value)
|
||||
}
|
||||
</script>
|
83
frontend/src/components/tasks/partials/projectSearch.vue
Normal file
83
frontend/src/components/tasks/partials/projectSearch.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<Multiselect
|
||||
class="control is-expanded"
|
||||
:placeholder="$t('project.search')"
|
||||
:search-results="foundProjects"
|
||||
label="title"
|
||||
:select-placeholder="$t('project.searchSelect')"
|
||||
:model-value="project"
|
||||
@update:modelValue="Object.assign(project, $event)"
|
||||
@select="select"
|
||||
@search="findProjects"
|
||||
>
|
||||
<template #searchResult="{option}">
|
||||
<span
|
||||
v-if="projectStore.getAncestors(option).length > 1"
|
||||
class="has-text-grey"
|
||||
>
|
||||
{{ projectStore.getAncestors(option).filter(p => p.id !== option.id).map(p => getProjectTitle(p)).join(' > ') }} >
|
||||
</span>
|
||||
{{ getProjectTitle(option) }}
|
||||
</template>
|
||||
</Multiselect>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {reactive, ref, watch} from 'vue'
|
||||
import type {PropType} from 'vue'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
|
||||
import ProjectModel from '@/models/project'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<IProject>,
|
||||
required: false,
|
||||
},
|
||||
savedFiltersOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const project: IProject = reactive(new ProjectModel())
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newProject) => Object.assign(project, newProject),
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const foundProjects = ref<IProject[]>([])
|
||||
function findProjects(query: string) {
|
||||
if (query === '') {
|
||||
select(null)
|
||||
}
|
||||
|
||||
if (props.savedFiltersOnly) {
|
||||
foundProjects.value = projectStore.searchSavedFilter(query)
|
||||
return
|
||||
}
|
||||
|
||||
foundProjects.value = projectStore.searchProject(query)
|
||||
}
|
||||
|
||||
function select(p: IProject | null) {
|
||||
if (p === null) {
|
||||
Object.assign(project, {id: 0})
|
||||
}
|
||||
Object.assign(project, p)
|
||||
emit('update:modelValue', project)
|
||||
}
|
||||
</script>
|
129
frontend/src/components/tasks/partials/quick-add-magic.vue
Normal file
129
frontend/src/components/tasks/partials/quick-add-magic.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<template v-if="mode !== 'disabled' && prefixes !== undefined">
|
||||
<BaseButton
|
||||
v-tooltip="$t('task.quickAddMagic.hint')"
|
||||
class="icon is-small show-helper-text"
|
||||
:aria-label="$t('task.quickAddMagic.hint')"
|
||||
:class="{'is-highlighted': highlightHintIcon}"
|
||||
@click="() => visible = true"
|
||||
>
|
||||
<icon :icon="['far', 'circle-question']" />
|
||||
</BaseButton>
|
||||
<modal
|
||||
:enabled="visible"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => visible = false"
|
||||
>
|
||||
<card
|
||||
class="has-no-shadow"
|
||||
:title="$t('task.quickAddMagic.title')"
|
||||
>
|
||||
<p>{{ $t('task.quickAddMagic.intro') }}</p>
|
||||
|
||||
<h3>{{ $t('task.attributes.labels') }}</h3>
|
||||
<p>
|
||||
{{ $t('task.quickAddMagic.label1', {prefix: prefixes.label}) }}
|
||||
{{ $t('task.quickAddMagic.label2') }}
|
||||
{{ $t('task.quickAddMagic.multiple') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('task.quickAddMagic.label3') }}
|
||||
{{ $t('task.quickAddMagic.label4', {prefix: prefixes.label}) }}
|
||||
</p>
|
||||
|
||||
<h3>{{ $t('task.attributes.priority') }}</h3>
|
||||
<p>
|
||||
{{ $t('task.quickAddMagic.priority1', {prefix: prefixes.priority}) }}
|
||||
{{ $t('task.quickAddMagic.priority2') }}
|
||||
</p>
|
||||
|
||||
<h3>{{ $t('task.attributes.assignees') }}</h3>
|
||||
<p>
|
||||
{{ $t('task.quickAddMagic.assignees', {prefix: prefixes.assignee}) }}
|
||||
{{ $t('task.quickAddMagic.multiple') }}
|
||||
</p>
|
||||
|
||||
<h3>{{ $t('quickActions.projects') }}</h3>
|
||||
<p>
|
||||
{{ $t('task.quickAddMagic.project1', {prefix: prefixes.project}) }}
|
||||
{{ $t('task.quickAddMagic.project2') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('task.quickAddMagic.project3') }}
|
||||
{{ $t('task.quickAddMagic.project4', {prefix: prefixes.project}) }}
|
||||
</p>
|
||||
|
||||
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>
|
||||
<p>
|
||||
{{ $t('task.quickAddMagic.date') }}
|
||||
</p>
|
||||
<ul>
|
||||
<!-- Not localized because these only work in english -->
|
||||
<li>Today</li>
|
||||
<li>Tomorrow</li>
|
||||
<li>Next monday</li>
|
||||
<li>This weekend</li>
|
||||
<li>Later this week</li>
|
||||
<li>Later next week</li>
|
||||
<li>Next week</li>
|
||||
<li>Next month</li>
|
||||
<li>End of month</li>
|
||||
<li>In 5 days [hours/weeks/months]</li>
|
||||
<li>Tuesday ({{ $t('task.quickAddMagic.dateWeekday') }})</li>
|
||||
<li>17/02/2021</li>
|
||||
<li>Feb 17 ({{ $t('task.quickAddMagic.dateCurrentYear') }})</li>
|
||||
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
|
||||
</ul>
|
||||
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
|
||||
|
||||
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
|
||||
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
|
||||
<p>{{ $t('misc.forExample') }}</p>
|
||||
<ul>
|
||||
<!-- Not localized because these only work in english -->
|
||||
<li>Every day</li>
|
||||
<li>Every 3 days</li>
|
||||
<li>Every week</li>
|
||||
<li>Every 2 weeks</li>
|
||||
<li>Every month</li>
|
||||
<li>Every 6 months</li>
|
||||
<li>Every year</li>
|
||||
<li>Every 2 years</li>
|
||||
</ul>
|
||||
</card>
|
||||
</modal>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {PREFIXES} from '@/modules/parseTaskText'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
defineProps<{
|
||||
highlightHintIcon?: boolean,
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const visible = ref(false)
|
||||
const mode = computed(() => authStore.settings.frontendSettings.quickAddMagicMode)
|
||||
|
||||
const prefixes = computed(() => PREFIXES[mode.value])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.show-helper-text {
|
||||
// Bulma adds pointer-events: none to the icon so we need to override it back here.
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.is-highlighted {
|
||||
color: inherit !important;
|
||||
}
|
||||
</style>
|
474
frontend/src/components/tasks/partials/relatedTasks.vue
Normal file
474
frontend/src/components/tasks/partials/relatedTasks.vue
Normal file
@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<div class="task-relations">
|
||||
<x-button
|
||||
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
|
||||
id="showRelatedTasksFormButton"
|
||||
v-tooltip="$t('task.relation.add')"
|
||||
class="is-pulled-right add-task-relation-button d-print-none"
|
||||
:class="{'is-active': showNewRelationForm}"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
:shadow="false"
|
||||
@click="showNewRelationForm = !showNewRelationForm"
|
||||
/>
|
||||
<transition-group name="fade">
|
||||
<template v-if="editEnabled && showCreate">
|
||||
<label
|
||||
key="label"
|
||||
class="label"
|
||||
>
|
||||
{{ $t('task.relation.new') }}
|
||||
<CustomTransition name="fade">
|
||||
<span
|
||||
v-if="taskRelationService.loading"
|
||||
class="is-inline-flex"
|
||||
>
|
||||
<span class="loader is-inline-block mr-2" />
|
||||
{{ $t('misc.saving') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="!taskRelationService.loading && saved"
|
||||
class="has-text-success"
|
||||
>
|
||||
{{ $t('misc.saved') }}
|
||||
</span>
|
||||
</CustomTransition>
|
||||
</label>
|
||||
<div
|
||||
key="field-search"
|
||||
class="field"
|
||||
>
|
||||
<Multiselect
|
||||
v-model="newTaskRelation.task"
|
||||
v-focus
|
||||
:placeholder="$t('task.relation.searchPlaceholder')"
|
||||
:loading="taskService.loading"
|
||||
:search-results="mappedFoundTasks"
|
||||
label="title"
|
||||
:creatable="true"
|
||||
:create-placeholder="$t('task.relation.createPlaceholder')"
|
||||
@search="findTasks"
|
||||
@create="createAndRelateTask"
|
||||
>
|
||||
<template #searchResult="{option: task}">
|
||||
<span
|
||||
v-if="typeof task !== 'string'"
|
||||
class="search-result"
|
||||
:class="{'is-strikethrough': task.done}"
|
||||
>
|
||||
<span
|
||||
v-if="task.projectId !== projectId"
|
||||
class="different-project"
|
||||
>
|
||||
<span
|
||||
v-if="task.differentProject !== null"
|
||||
v-tooltip="$t('task.relation.differentProject')"
|
||||
>
|
||||
{{ task.differentProject }} >
|
||||
</span>
|
||||
</span>
|
||||
{{ task.title }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="search-result"
|
||||
>
|
||||
{{ task }}
|
||||
</span>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
<div
|
||||
key="field-kind"
|
||||
class="field has-addons mb-4"
|
||||
>
|
||||
<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
|
||||
v-for="rk in RELATION_KINDS"
|
||||
:key="`option_${rk}`"
|
||||
:value="rk"
|
||||
>
|
||||
{{ $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
|
||||
v-for="rts in mappedRelatedTasks"
|
||||
:key="rts.kind"
|
||||
class="related-tasks"
|
||||
>
|
||||
<span class="title">{{ rts.title }}</span>
|
||||
<div class="tasks">
|
||||
<div
|
||||
v-for="t in rts.tasks"
|
||||
:key="t.id"
|
||||
class="task"
|
||||
>
|
||||
<div class="is-flex is-align-items-center">
|
||||
<Fancycheckbox
|
||||
v-model="t.done"
|
||||
class="task-done-checkbox"
|
||||
@update:modelValue="toggleTaskDone(t)"
|
||||
/>
|
||||
<router-link
|
||||
:to="{ name: route.name as string, params: { id: t.id } }"
|
||||
:class="{ 'is-strikethrough': t.done}"
|
||||
>
|
||||
<span
|
||||
v-if="t.projectId !== projectId"
|
||||
class="different-project"
|
||||
>
|
||||
<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"
|
||||
class="remove"
|
||||
@click="setRelationToDelete({
|
||||
relationKind: rts.kind,
|
||||
otherTaskId: t.id
|
||||
})"
|
||||
>
|
||||
<icon icon="trash-alt" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="showNoRelationsNotice && Object.keys(relatedTasks).length === 0"
|
||||
class="none"
|
||||
>
|
||||
{{ $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>
|
292
frontend/src/components/tasks/partials/reminder-detail.vue
Normal file
292
frontend/src/components/tasks/partials/reminder-detail.vue
Normal file
@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div>
|
||||
<Popup @close="showFormSwitch = null">
|
||||
<template #trigger="{toggle}">
|
||||
<SimpleButton
|
||||
v-tooltip="reminder.reminder && reminder.relativeTo !== null ? formatDateShort(reminder.reminder) : null"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ reminderText }}
|
||||
</SimpleButton>
|
||||
</template>
|
||||
<template #content="{isOpen, close}">
|
||||
<Card
|
||||
class="reminder-options-popup"
|
||||
:class="{'is-open': isOpen}"
|
||||
:padding="false"
|
||||
>
|
||||
<div
|
||||
v-if="activeForm === null"
|
||||
class="options"
|
||||
>
|
||||
<SimpleButton
|
||||
v-for="(p, k) in presets"
|
||||
:key="k"
|
||||
class="option-button"
|
||||
:class="{'currently-active': p.relativePeriod === modelValue?.relativePeriod && modelValue?.relativeTo === p.relativeTo}"
|
||||
@click="setReminderFromPreset(p, close)"
|
||||
>
|
||||
{{ formatReminder(p) }}
|
||||
</SimpleButton>
|
||||
<SimpleButton
|
||||
class="option-button"
|
||||
:class="{'currently-active': typeof modelValue !== 'undefined' && modelValue?.relativeTo !== null && presets.find(p => p.relativePeriod === modelValue?.relativePeriod && modelValue?.relativeTo === p.relativeTo) === undefined}"
|
||||
@click="showFormSwitch = 'relative'"
|
||||
>
|
||||
{{ $t('task.reminder.custom') }}
|
||||
</SimpleButton>
|
||||
<SimpleButton
|
||||
class="option-button"
|
||||
:class="{'currently-active': modelValue?.relativeTo === null}"
|
||||
@click="showFormSwitch = 'absolute'"
|
||||
>
|
||||
{{ $t('task.reminder.dateAndTime') }}
|
||||
</SimpleButton>
|
||||
</div>
|
||||
|
||||
<ReminderPeriod
|
||||
v-if="activeForm === 'relative'"
|
||||
v-model="reminder"
|
||||
@update:modelValue="updateDataAndMaybeClose(close)"
|
||||
/>
|
||||
|
||||
<DatepickerInline
|
||||
v-if="activeForm === 'absolute'"
|
||||
v-model="reminderDate"
|
||||
@update:modelValue="setReminderDateAndClose(close)"
|
||||
/>
|
||||
|
||||
<x-button
|
||||
v-if="showFormSwitch !== null"
|
||||
class="reminder__close-button"
|
||||
:shadow="false"
|
||||
@click="updateDataAndMaybeClose(close)"
|
||||
>
|
||||
{{ $t('misc.confirm') }}
|
||||
</x-button>
|
||||
</Card>
|
||||
</template>
|
||||
</Popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {SECONDS_A_DAY, SECONDS_A_HOUR} from '@/constants/date'
|
||||
import {IReminderPeriodRelativeTo, REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
|
||||
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
|
||||
import DatepickerInline from '@/components/input/datepickerInline.vue'
|
||||
import ReminderPeriod from '@/components/tasks/partials/reminder-period.vue'
|
||||
import Popup from '@/components/misc/popup.vue'
|
||||
|
||||
import TaskReminderModel from '@/models/taskReminder'
|
||||
import Card from '@/components/misc/card.vue'
|
||||
import SimpleButton from '@/components/input/SimpleButton.vue'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
clearAfterUpdate = false,
|
||||
defaultRelativeTo = REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE,
|
||||
} = defineProps<{
|
||||
modelValue?: ITaskReminder,
|
||||
clearAfterUpdate?: boolean,
|
||||
defaultRelativeTo?: null | IReminderPeriodRelativeTo,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const reminder = ref<ITaskReminder>(new TaskReminderModel())
|
||||
|
||||
const presets = computed<TaskReminderModel[]>(() => [
|
||||
{reminder: null, relativePeriod: 0, relativeTo: defaultRelativeTo},
|
||||
{reminder: null, relativePeriod: -2 * SECONDS_A_HOUR, relativeTo: defaultRelativeTo},
|
||||
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY, relativeTo: defaultRelativeTo},
|
||||
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 3, relativeTo: defaultRelativeTo},
|
||||
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 7, relativeTo: defaultRelativeTo},
|
||||
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 30, relativeTo: defaultRelativeTo},
|
||||
])
|
||||
const reminderDate = ref<Date|null>(null)
|
||||
|
||||
type availableForms = null | 'relative' | 'absolute'
|
||||
|
||||
const showFormSwitch = ref<availableForms>(null)
|
||||
|
||||
const activeForm = computed<availableForms>(() => {
|
||||
if (defaultRelativeTo === null) {
|
||||
return 'absolute'
|
||||
}
|
||||
|
||||
return showFormSwitch.value
|
||||
})
|
||||
|
||||
const reminderText = computed(() => {
|
||||
|
||||
if (reminder.value.relativeTo !== null) {
|
||||
return formatReminder(reminder.value)
|
||||
}
|
||||
|
||||
if (reminder.value.reminder !== null) {
|
||||
return formatDateShort(reminder.value.reminder)
|
||||
}
|
||||
|
||||
return t('task.addReminder')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
(newReminder) => {
|
||||
if(newReminder) {
|
||||
reminder.value = newReminder
|
||||
|
||||
if(newReminder.relativeTo === null) {
|
||||
reminderDate.value = new Date(newReminder.reminder)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reminder.value = new TaskReminderModel()
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function updateData() {
|
||||
emit('update:modelValue', reminder.value)
|
||||
|
||||
if (clearAfterUpdate) {
|
||||
reminder.value = new TaskReminderModel()
|
||||
}
|
||||
}
|
||||
|
||||
function setReminderDateAndClose(close) {
|
||||
reminder.value.reminder = reminderDate.value === null
|
||||
? null
|
||||
: new Date(reminderDate.value)
|
||||
reminder.value.relativeTo = null
|
||||
reminder.value.relativePeriod = 0
|
||||
updateDataAndMaybeClose(close)
|
||||
}
|
||||
|
||||
|
||||
function setReminderFromPreset(preset, close) {
|
||||
reminder.value = preset
|
||||
updateData()
|
||||
close()
|
||||
}
|
||||
|
||||
function updateDataAndMaybeClose(close) {
|
||||
updateData()
|
||||
if (clearAfterUpdate) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function formatReminder(reminder: TaskReminderModel) {
|
||||
const period = secondsToPeriod(reminder.relativePeriod)
|
||||
|
||||
if (period.amount === 0) {
|
||||
switch (reminder.relativeTo) {
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE:
|
||||
return t('task.reminder.onDueDate')
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE:
|
||||
return t('task.reminder.onStartDate')
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE:
|
||||
return t('task.reminder.onEndDate')
|
||||
}
|
||||
}
|
||||
|
||||
const amountAbs = Math.abs(period.amount)
|
||||
|
||||
let relativeTo = ''
|
||||
switch (reminder.relativeTo) {
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE:
|
||||
relativeTo = t('task.attributes.dueDate')
|
||||
break
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE:
|
||||
relativeTo = t('task.attributes.startDate')
|
||||
break
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE:
|
||||
relativeTo = t('task.attributes.endDate')
|
||||
break
|
||||
}
|
||||
|
||||
if (reminder.relativePeriod <= 0) {
|
||||
return t('task.reminder.before', {
|
||||
amount: amountAbs,
|
||||
unit: translateUnit(amountAbs, period.unit),
|
||||
type: relativeTo,
|
||||
})
|
||||
}
|
||||
|
||||
return t('task.reminder.after', {
|
||||
amount: amountAbs,
|
||||
unit: translateUnit(amountAbs, period.unit),
|
||||
type: relativeTo,
|
||||
})
|
||||
}
|
||||
|
||||
function translateUnit(amount: number, unit: PeriodUnit): string {
|
||||
switch (unit) {
|
||||
case 'seconds':
|
||||
return t('time.units.seconds', amount)
|
||||
case 'minutes':
|
||||
return t('time.units.minutes', amount)
|
||||
case 'hours':
|
||||
return t('time.units.hours', amount)
|
||||
case 'days':
|
||||
return t('time.units.days', amount)
|
||||
case 'weeks':
|
||||
return t('time.units.weeks', amount)
|
||||
case 'years':
|
||||
return t('time.units.years', amount)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:deep(.popup) {
|
||||
top: unset;
|
||||
}
|
||||
|
||||
.reminder-options-popup {
|
||||
width: 310px;
|
||||
z-index: 99;
|
||||
|
||||
@media screen and (max-width: ($tablet)) {
|
||||
width: calc(100vw - 5rem);
|
||||
}
|
||||
|
||||
.option-button {
|
||||
font-size: .85rem;
|
||||
border-radius: 0;
|
||||
padding: .5rem;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reminder__close-button {
|
||||
margin: .5rem;
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.currently-active {
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
142
frontend/src/components/tasks/partials/reminder-period.vue
Normal file
142
frontend/src/components/tasks/partials/reminder-period.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div
|
||||
class="reminder-period control"
|
||||
>
|
||||
<input
|
||||
v-model.number="period.duration"
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@change="updateData"
|
||||
>
|
||||
|
||||
<div class="select">
|
||||
<select
|
||||
v-model="period.durationUnit"
|
||||
@change="updateData"
|
||||
>
|
||||
<option value="minutes">
|
||||
{{ $t('time.units.minutes', period.duration) }}
|
||||
</option>
|
||||
<option value="hours">
|
||||
{{ $t('time.units.hours', period.duration) }}
|
||||
</option>
|
||||
<option value="days">
|
||||
{{ $t('time.units.days', period.duration) }}
|
||||
</option>
|
||||
<option value="weeks">
|
||||
{{ $t('time.units.weeks', period.duration) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="select">
|
||||
<select
|
||||
v-model.number="period.sign"
|
||||
@change="updateData"
|
||||
>
|
||||
<option value="-1">
|
||||
{{ $t('task.reminder.beforeShort') }}
|
||||
</option>
|
||||
<option value="1">
|
||||
{{ $t('task.reminder.afterShort') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="select">
|
||||
<select
|
||||
v-model="period.relativeTo"
|
||||
@change="updateData"
|
||||
>
|
||||
<option :value="REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE">
|
||||
{{ $t('task.attributes.dueDate') }}
|
||||
</option>
|
||||
<option :value="REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE">
|
||||
{{ $t('task.attributes.startDate') }}
|
||||
</option>
|
||||
<option :value="REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE">
|
||||
{{ $t('task.attributes.endDate') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch} from 'vue'
|
||||
|
||||
import {periodToSeconds, PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
|
||||
|
||||
import TaskReminderModel from '@/models/taskReminder'
|
||||
|
||||
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
|
||||
import {REMINDER_PERIOD_RELATIVE_TO_TYPES, type IReminderPeriodRelativeTo} from '@/types/IReminderPeriodRelativeTo'
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
} = defineProps<{
|
||||
modelValue?: ITaskReminder,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const reminder = ref<ITaskReminder>(new TaskReminderModel())
|
||||
|
||||
interface PeriodInput {
|
||||
duration: number,
|
||||
durationUnit: PeriodUnit,
|
||||
relativeTo: IReminderPeriodRelativeTo,
|
||||
sign: -1 | 1,
|
||||
}
|
||||
|
||||
const period = ref<PeriodInput>({
|
||||
duration: 0,
|
||||
durationUnit: 'hours',
|
||||
relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE,
|
||||
sign: -1,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
(value) => {
|
||||
const p = secondsToPeriod(value?.relativePeriod)
|
||||
period.value.durationUnit = p.unit
|
||||
period.value.duration = Math.abs(p.amount)
|
||||
period.value.relativeTo = value?.relativeTo || REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => period.value.duration,
|
||||
value => {
|
||||
if (value < 0) {
|
||||
period.value.duration = value * -1
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function updateData() {
|
||||
reminder.value.relativePeriod = period.value.sign * periodToSeconds(Math.abs(period.value.duration), period.value.durationUnit)
|
||||
reminder.value.relativeTo = period.value.relativeTo
|
||||
reminder.value.reminder = null
|
||||
|
||||
useDebounceFn(() => emit('update:modelValue', reminder.value), 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reminder-period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
padding: .5rem .5rem 0;
|
||||
|
||||
.input, .select select {
|
||||
width: 100% !important;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
26
frontend/src/components/tasks/partials/reminders.story.vue
Normal file
26
frontend/src/components/tasks/partials/reminders.story.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import reminders from './reminders.vue'
|
||||
import {ref} from 'vue'
|
||||
import ReminderDetail from '@/components/tasks/partials/reminder-detail.vue'
|
||||
|
||||
const reminderNow = ref({reminder: new Date(), relativePeriod: 0, relativeTo: null } )
|
||||
const relativeReminder = ref({reminder: null, relativePeriod: 1, relativeTo: 'due_date' } )
|
||||
const newReminder = ref(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="Default">
|
||||
<reminders />
|
||||
</Variant>
|
||||
<Variant title="Reminder Detail with fixed date">
|
||||
<ReminderDetail v-model="reminderNow" />
|
||||
</Variant>
|
||||
<Variant title="Reminder Detail with relative date">
|
||||
<ReminderDetail v-model="relativeReminder" />
|
||||
</Variant>
|
||||
<Variant title="New Reminder Detail">
|
||||
<ReminderDetail v-model="newReminder" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
129
frontend/src/components/tasks/partials/reminders.vue
Normal file
129
frontend/src/components/tasks/partials/reminders.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="reminders">
|
||||
<div
|
||||
v-for="(r, index) in reminders"
|
||||
:key="index"
|
||||
:class="{ 'overdue': r.reminder < new Date() }"
|
||||
class="reminder-input"
|
||||
>
|
||||
<ReminderDetail
|
||||
v-model="reminders[index]"
|
||||
class="reminder-detail"
|
||||
:disabled="disabled"
|
||||
:default-relative-to="defaultRelativeTo"
|
||||
@update:modelValue="updateData"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="!disabled"
|
||||
class="remove"
|
||||
@click="removeReminderByIndex(index)"
|
||||
>
|
||||
<icon icon="times" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<ReminderDetail
|
||||
:disabled="disabled"
|
||||
:clear-after-update="true"
|
||||
:default-relative-to="defaultRelativeTo"
|
||||
@update:modelValue="addNewReminder"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, computed} from 'vue'
|
||||
|
||||
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ReminderDetail from '@/components/tasks/partials/reminder-detail.vue'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
disabled = false,
|
||||
} = defineProps<{
|
||||
modelValue: ITask,
|
||||
disabled?: boolean,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const reminders = ref<ITaskReminder[]>([])
|
||||
|
||||
watch(
|
||||
() => modelValue.reminders,
|
||||
(newVal) => {
|
||||
reminders.value = newVal
|
||||
},
|
||||
{immediate: true, deep: true}, // deep watcher so that we get the resolved date after updating the task
|
||||
)
|
||||
|
||||
const defaultRelativeTo = computed(() => {
|
||||
if (typeof modelValue === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (modelValue?.dueDate) {
|
||||
return REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE
|
||||
}
|
||||
|
||||
if (modelValue.dueDate === null && modelValue.startDate !== null) {
|
||||
return REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE
|
||||
}
|
||||
|
||||
if (modelValue.dueDate === null && modelValue.startDate === null && modelValue.endDate !== null) {
|
||||
return REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
function updateData() {
|
||||
emit('update:modelValue', {
|
||||
...modelValue,
|
||||
reminders: reminders.value,
|
||||
})
|
||||
}
|
||||
|
||||
function addNewReminder(newReminder: ITaskReminder) {
|
||||
if (newReminder === null) {
|
||||
return
|
||||
}
|
||||
reminders.value.push(newReminder)
|
||||
updateData()
|
||||
}
|
||||
|
||||
function removeReminderByIndex(index: number) {
|
||||
reminders.value.splice(index, 1)
|
||||
updateData()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reminder-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.overdue :deep(.datepicker .show) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
&::last-child {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.reminder-detail {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.remove {
|
||||
color: var(--danger);
|
||||
vertical-align: top;
|
||||
padding-left: .5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
171
frontend/src/components/tasks/partials/repeatAfter.vue
Normal file
171
frontend/src/components/tasks/partials/repeatAfter.vue
Normal file
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="control repeat-after-input">
|
||||
<div class="buttons has-addons is-centered mt-2">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
class="is-small"
|
||||
@click="() => setRepeatAfter(1, 'days')"
|
||||
>
|
||||
{{ $t('task.repeat.everyDay') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
class="is-small"
|
||||
@click="() => setRepeatAfter(1, 'weeks')"
|
||||
>
|
||||
{{ $t('task.repeat.everyWeek') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
class="is-small"
|
||||
@click="() => setRepeatAfter(30, 'days')"
|
||||
>
|
||||
{{ $t('task.repeat.every30d') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<div class="is-flex is-align-items-center mb-2">
|
||||
<label
|
||||
for="repeatMode"
|
||||
class="is-fullwidth"
|
||||
>
|
||||
{{ $t('task.repeat.mode') }}:
|
||||
</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select
|
||||
id="repeatMode"
|
||||
v-model="task.repeatMode"
|
||||
@change="updateData"
|
||||
>
|
||||
<option :value="TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT">
|
||||
{{ $t('misc.default') }}
|
||||
</option>
|
||||
<option :value="TASK_REPEAT_MODES.REPEAT_MODE_MONTH">
|
||||
{{ $t('task.repeat.monthly') }}
|
||||
</option>
|
||||
<option :value="TASK_REPEAT_MODES.REPEAT_MODE_FROM_CURRENT_DATE">
|
||||
{{ $t('task.repeat.fromCurrentDate') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="task.repeatMode !== TASK_REPEAT_MODES.REPEAT_MODE_MONTH"
|
||||
class="is-flex"
|
||||
>
|
||||
<p class="pr-4">
|
||||
{{ $t('task.repeat.each') }}
|
||||
</p>
|
||||
<div class="field has-addons is-fullwidth">
|
||||
<div class="control">
|
||||
<input
|
||||
v-model="repeatAfter.amount"
|
||||
:disabled="disabled || undefined"
|
||||
class="input"
|
||||
:placeholder="$t('task.repeat.specifyAmount')"
|
||||
type="number"
|
||||
min="0"
|
||||
@change="updateData"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select
|
||||
v-model="repeatAfter.type"
|
||||
:disabled="disabled || undefined"
|
||||
@change="updateData"
|
||||
>
|
||||
<option value="hours">
|
||||
{{ $t('task.repeat.hours') }}
|
||||
</option>
|
||||
<option value="days">
|
||||
{{ $t('task.repeat.days') }}
|
||||
</option>
|
||||
<option value="weeks">
|
||||
{{ $t('task.repeat.weeks') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, watch, type PropType} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {error} from '@/message'
|
||||
|
||||
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
|
||||
import type {IRepeatAfter} from '@/types/IRepeatAfter'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import TaskModel from '@/models/task'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<ITask>,
|
||||
default: () => ({}),
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const task = ref<ITask>(new TaskModel())
|
||||
const repeatAfter = reactive({
|
||||
amount: 0,
|
||||
type: '',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value: ITask) => {
|
||||
task.value = value
|
||||
if (typeof value.repeatAfter !== 'undefined') {
|
||||
Object.assign(repeatAfter, value.repeatAfter)
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function updateData() {
|
||||
if (!task.value ||
|
||||
(task.value.repeatMode === TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT && repeatAfter.amount === 0) ||
|
||||
(task.value.repeatMode === TASK_REPEAT_MODES.REPEAT_MODE_FROM_CURRENT_DATE && repeatAfter.amount === 0)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (task.value.repeatMode === TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT && repeatAfter.amount < 0) {
|
||||
error({message: t('task.repeat.invalidAmount')})
|
||||
return
|
||||
}
|
||||
|
||||
Object.assign(task.value.repeatAfter, repeatAfter)
|
||||
emit('update:modelValue', task.value)
|
||||
}
|
||||
|
||||
function setRepeatAfter(amount: number, type: IRepeatAfter['type']) {
|
||||
Object.assign(repeatAfter, { amount, type})
|
||||
updateData()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
p {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 2rem;
|
||||
}
|
||||
</style>
|
525
frontend/src/components/tasks/partials/singleTaskInProject.vue
Normal file
525
frontend/src/components/tasks/partials/singleTaskInProject.vue
Normal file
@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="taskContainerRef"
|
||||
:class="{'is-loading': taskService.loading}"
|
||||
class="task loader-container single-task"
|
||||
tabindex="-1"
|
||||
@mouseup.stop.self="openTaskDetail"
|
||||
@mousedown.stop.self="focusTaskLink"
|
||||
>
|
||||
<Fancycheckbox
|
||||
v-model="task.done"
|
||||
:disabled="(isArchived || disabled) && !canMarkAsDone"
|
||||
@update:modelValue="markAsDone"
|
||||
/>
|
||||
|
||||
<ColorBubble
|
||||
v-if="!showProjectSeparately && projectColor !== '' && currentProject?.id !== task.projectId"
|
||||
:color="projectColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
|
||||
<div
|
||||
:class="{ 'done': task.done, 'show-project': showProject && project}"
|
||||
class="tasktext"
|
||||
@mouseup.stop.self="openTaskDetail"
|
||||
@mousedown.stop.self="focusTaskLink"
|
||||
>
|
||||
<span>
|
||||
<router-link
|
||||
v-if="showProject && typeof project !== 'undefined'"
|
||||
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
||||
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
|
||||
class="task-project mr-1"
|
||||
:class="{'mr-2': task.hexColor !== ''}"
|
||||
>
|
||||
{{ project.title }}
|
||||
</router-link>
|
||||
|
||||
<ColorBubble
|
||||
v-if="task.hexColor !== ''"
|
||||
:color="getHexColor(task.hexColor)"
|
||||
class="mr-1"
|
||||
/>
|
||||
|
||||
<PriorityLabel
|
||||
:priority="task.priority"
|
||||
:done="task.done"
|
||||
class="pr-2"
|
||||
/>
|
||||
|
||||
<router-link
|
||||
ref="taskLink"
|
||||
:to="taskDetailRoute"
|
||||
class="task-link"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ task.title }}
|
||||
</router-link>
|
||||
</span>
|
||||
|
||||
<Labels
|
||||
v-if="task.labels.length > 0"
|
||||
class="labels ml-2 mr-1"
|
||||
:labels="task.labels"
|
||||
/>
|
||||
|
||||
<AssigneeList
|
||||
v-if="task.assignees.length > 0"
|
||||
:assignees="task.assignees"
|
||||
:avatar-size="25"
|
||||
class="ml-1"
|
||||
:inline="true"
|
||||
/>
|
||||
|
||||
<!-- FIXME: use popup -->
|
||||
<BaseButton
|
||||
v-if="+new Date(task.dueDate) > 0"
|
||||
v-tooltip="formatDateLong(task.dueDate)"
|
||||
class="dueDate"
|
||||
@click.prevent.stop="showDefer = !showDefer"
|
||||
>
|
||||
<time
|
||||
:datetime="formatISO(task.dueDate)"
|
||||
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
|
||||
class="is-italic"
|
||||
:aria-expanded="showDefer ? 'true' : 'false'"
|
||||
>
|
||||
– {{ $t('task.detail.due', {at: dueDateFormatted}) }}
|
||||
</time>
|
||||
</BaseButton>
|
||||
<CustomTransition name="fade">
|
||||
<DeferTask
|
||||
v-if="+new Date(task.dueDate) > 0 && showDefer"
|
||||
ref="deferDueDate"
|
||||
v-model="task"
|
||||
/>
|
||||
</CustomTransition>
|
||||
|
||||
<span>
|
||||
<span
|
||||
v-if="task.attachments.length > 0"
|
||||
class="project-task-icon"
|
||||
>
|
||||
<icon icon="paperclip" />
|
||||
</span>
|
||||
<span
|
||||
v-if="!isEditorContentEmpty(task.description)"
|
||||
class="project-task-icon"
|
||||
>
|
||||
<icon icon="align-left" />
|
||||
</span>
|
||||
<span
|
||||
v-if="task.repeatAfter.amount > 0"
|
||||
class="project-task-icon"
|
||||
>
|
||||
<icon icon="history" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ChecklistSummary :task="task" />
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
v-if="task.percentDone > 0"
|
||||
:value="task.percentDone * 100"
|
||||
is-small
|
||||
/>
|
||||
|
||||
<ColorBubble
|
||||
v-if="showProjectSeparately && projectColor !== '' && currentProject?.id !== task.projectId"
|
||||
:color="projectColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
|
||||
<router-link
|
||||
v-if="showProjectSeparately"
|
||||
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
||||
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
|
||||
class="task-project"
|
||||
>
|
||||
{{ project.title }}
|
||||
</router-link>
|
||||
|
||||
<BaseButton
|
||||
:class="{'is-favorite': task.isFavorite}"
|
||||
class="favorite"
|
||||
@click="toggleFavorite"
|
||||
>
|
||||
<icon
|
||||
v-if="task.isFavorite"
|
||||
icon="star"
|
||||
/>
|
||||
<icon
|
||||
v-else
|
||||
:icon="['far', 'star']"
|
||||
/>
|
||||
</BaseButton>
|
||||
<slot />
|
||||
</div>
|
||||
<template v-if="typeof task.relatedTasks?.subtask !== 'undefined'">
|
||||
<template v-for="subtask in task.relatedTasks.subtask">
|
||||
<template v-if="getTaskById(subtask.id)">
|
||||
<single-task-in-project
|
||||
:key="subtask.id"
|
||||
:the-task="getTaskById(subtask.id)"
|
||||
:disabled="disabled"
|
||||
:can-mark-as-done="canMarkAsDone"
|
||||
:all-tasks="allTasks"
|
||||
class="subtask-nested"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, shallowReactive, onMounted, onBeforeUnmount, computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import TaskModel, {getHexColor} from '@/models/task'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
|
||||
import Labels from '@/components/tasks/partials//labels.vue'
|
||||
import DeferTask from '@/components/tasks/partials//defer-task.vue'
|
||||
import ChecklistSummary from '@/components/tasks/partials/checklist-summary.vue'
|
||||
|
||||
import ProgressBar from '@/components/misc/ProgressBar.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
|
||||
import {success} from '@/message'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
|
||||
import {useIntervalFn} from '@vueuse/core'
|
||||
import {playPopSound} from '@/helpers/playPop'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||
|
||||
const {
|
||||
theTask,
|
||||
isArchived = false,
|
||||
showProject = false,
|
||||
disabled = false,
|
||||
canMarkAsDone = true,
|
||||
allTasks = [],
|
||||
} = defineProps<{
|
||||
theTask: ITask,
|
||||
isArchived?: boolean,
|
||||
showProject?: boolean,
|
||||
disabled?: boolean,
|
||||
canMarkAsDone?: boolean,
|
||||
allTasks?: ITask[],
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['taskUpdated'])
|
||||
|
||||
function getTaskById(taskId: number): ITask | undefined {
|
||||
if (typeof allTasks === 'undefined' || allTasks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return allTasks.find(t => t.id === taskId)
|
||||
}
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const task = ref<ITask>(new TaskModel())
|
||||
const showDefer = ref(false)
|
||||
|
||||
watch(
|
||||
() => theTask,
|
||||
newVal => {
|
||||
task.value = newVal
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
task.value = theTask
|
||||
document.addEventListener('click', hideDeferDueDatePopup)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', hideDeferDueDatePopup)
|
||||
})
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
const project = computed(() => projectStore.projects[task.value.projectId])
|
||||
const projectColor = computed(() => project.value ? project.value?.hexColor : '')
|
||||
|
||||
const showProjectSeparately = computed(() => !showProject && currentProject.value?.id !== task.value.projectId && project.value)
|
||||
|
||||
const currentProject = computed(() => {
|
||||
return typeof baseStore.currentProject === 'undefined' ? {
|
||||
id: 0,
|
||||
title: '',
|
||||
} : baseStore.currentProject
|
||||
})
|
||||
|
||||
const taskDetailRoute = computed(() => ({
|
||||
name: 'task.detail',
|
||||
params: {id: task.value.id},
|
||||
// TODO: re-enable opening task detail in modal
|
||||
// state: { backdropView: router.currentRoute.value.fullPath },
|
||||
}))
|
||||
|
||||
function updateDueDate() {
|
||||
if (!task.value.dueDate) {
|
||||
return
|
||||
}
|
||||
|
||||
dueDateFormatted.value = formatDateSince(task.value.dueDate)
|
||||
}
|
||||
|
||||
const dueDateFormatted = ref('')
|
||||
useIntervalFn(updateDueDate, 60_000, {
|
||||
immediateCallback: true,
|
||||
})
|
||||
onMounted(updateDueDate)
|
||||
|
||||
|
||||
async function markAsDone(checked: boolean) {
|
||||
const updateFunc = async () => {
|
||||
const newTask = await taskStore.update(task.value)
|
||||
task.value = newTask
|
||||
if (checked && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
|
||||
playPopSound()
|
||||
}
|
||||
emit('taskUpdated', newTask)
|
||||
success({
|
||||
message: task.value.done ?
|
||||
t('task.doneSuccess') :
|
||||
t('task.undoneSuccess'),
|
||||
}, [{
|
||||
title: t('task.undo'),
|
||||
callback: () => undoDone(checked),
|
||||
}])
|
||||
updateDueDate()
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
|
||||
} else {
|
||||
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
}
|
||||
}
|
||||
|
||||
function undoDone(checked: boolean) {
|
||||
task.value.done = !task.value.done
|
||||
markAsDone(!checked)
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
task.value = await taskStore.toggleFavorite(task.value)
|
||||
emit('taskUpdated', task.value)
|
||||
}
|
||||
|
||||
const deferDueDate = ref<typeof DeferTask | null>(null)
|
||||
|
||||
function hideDeferDueDatePopup(e) {
|
||||
if (!showDefer.value) {
|
||||
return
|
||||
}
|
||||
closeWhenClickedOutside(e, deferDueDate.value.$el, () => {
|
||||
showDefer.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const taskLink = ref<HTMLElement | null>(null)
|
||||
const taskContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function hasTextSelected() {
|
||||
const isTextSelected = window.getSelection().toString()
|
||||
return !(typeof isTextSelected === 'undefined' || isTextSelected === '' || isTextSelected === '\n')
|
||||
}
|
||||
|
||||
function openTaskDetail() {
|
||||
if (!hasTextSelected()) {
|
||||
taskLink.value.$el.click()
|
||||
}
|
||||
}
|
||||
|
||||
function focusTaskLink() {
|
||||
if (!hasTextSelected()) {
|
||||
taskContainerRef.value.focus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: .4rem;
|
||||
transition: background-color $transition;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: $radius;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
|
||||
&:focus-within, &:focus {
|
||||
box-shadow: 0 0 0 2px hsla(var(--primary-hsl), 0.5);
|
||||
|
||||
a.task-link {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tasktext,
|
||||
&.tasktext {
|
||||
text-overflow: ellipsis;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
hyphens: auto;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
flex: 1 0 50%;
|
||||
|
||||
.dueDate {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.overdue {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.task-project {
|
||||
width: auto;
|
||||
color: var(--grey-400);
|
||||
font-size: .9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
vertical-align: bottom;
|
||||
margin-left: 5px;
|
||||
height: 27px;
|
||||
width: 27px;
|
||||
}
|
||||
|
||||
.project-task-icon {
|
||||
margin-left: 6px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text);
|
||||
transition: color ease $transition-duration;
|
||||
|
||||
&:hover {
|
||||
color: var(--grey-900);
|
||||
}
|
||||
}
|
||||
|
||||
.favorite {
|
||||
opacity: 1;
|
||||
text-align: center;
|
||||
width: 27px;
|
||||
transition: opacity $transition, color $transition;
|
||||
|
||||
&:hover {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
&.is-favorite {
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
@media(hover: hover) and (pointer: fine) {
|
||||
& .favorite {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:deep(.fancycheckbox) {
|
||||
height: 18px;
|
||||
padding-top: 0;
|
||||
padding-right: .5rem;
|
||||
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tasktext.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
|
||||
span.parent-tasks {
|
||||
color: var(--grey-500);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.show-project .parent-tasks {
|
||||
padding-left: .25rem;
|
||||
}
|
||||
|
||||
.remove {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.settings {
|
||||
float: right;
|
||||
width: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.loader-container.is-loading:after {
|
||||
top: calc(50% - 1rem);
|
||||
left: calc(50% - 1rem);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-left-color: var(--grey-300);
|
||||
border-bottom-color: var(--grey-300);
|
||||
}
|
||||
}
|
||||
|
||||
.subtask-nested {
|
||||
margin-left: 1.75rem;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="task">
|
||||
<span>
|
||||
<span
|
||||
v-if="showProject && typeof project !== 'undefined'"
|
||||
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
||||
class="task-project"
|
||||
:class="{'mr-2': task.hexColor !== ''}"
|
||||
>
|
||||
{{ project.title }}
|
||||
</span>
|
||||
|
||||
<ColorBubble
|
||||
v-if="task.hexColor !== ''"
|
||||
:color="getHexColor(task.hexColor)"
|
||||
class="mr-1"
|
||||
/>
|
||||
|
||||
<PriorityLabel
|
||||
:priority="task.priority"
|
||||
:done="task.done"
|
||||
/>
|
||||
|
||||
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
|
||||
<span
|
||||
v-if="typeof task.relatedTasks?.parenttask !== 'undefined'"
|
||||
class="parent-tasks"
|
||||
>
|
||||
<template v-for="(pt, i) in task.relatedTasks.parenttask">
|
||||
{{ pt.title }}<template v-if="(i + 1) < task.relatedTasks.parenttask.length">, </template>
|
||||
</template>
|
||||
›
|
||||
</span>
|
||||
{{ task.title }}
|
||||
</span>
|
||||
|
||||
<Labels
|
||||
v-if="task.labels.length > 0"
|
||||
class="labels ml-2 mr-1"
|
||||
:labels="task.labels"
|
||||
/>
|
||||
|
||||
<AssigneeList
|
||||
v-if="task.assignees.length > 0"
|
||||
:assignees="task.assignees"
|
||||
:avatar-size="20"
|
||||
class="ml-1"
|
||||
:inline="true"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="+new Date(task.dueDate) > 0"
|
||||
v-tooltip="formatDateLong(task.dueDate)"
|
||||
class="dueDate"
|
||||
>
|
||||
<time
|
||||
:datetime="formatISO(task.dueDate)"
|
||||
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
|
||||
class="is-italic"
|
||||
>
|
||||
– {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }}
|
||||
</time>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<span
|
||||
v-if="task.attachments.length > 0"
|
||||
class="project-task-icon"
|
||||
>
|
||||
<icon icon="paperclip" />
|
||||
</span>
|
||||
<span
|
||||
v-if="task.description"
|
||||
class="project-task-icon"
|
||||
>
|
||||
<icon icon="align-left" />
|
||||
</span>
|
||||
<span
|
||||
v-if="task.repeatAfter.amount > 0"
|
||||
class="project-task-icon"
|
||||
>
|
||||
<icon icon="history" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ChecklistSummary :task="task" />
|
||||
|
||||
<progress
|
||||
v-if="task.percentDone > 0"
|
||||
class="progress is-small"
|
||||
:value="task.percentDone * 100"
|
||||
max="100"
|
||||
>
|
||||
{{ task.percentDone * 100 }}%
|
||||
</progress>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
import {getHexColor} from '@/models/task'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
|
||||
import Labels from '@/components/tasks/partials//labels.vue'
|
||||
import ChecklistSummary from '@/components/tasks/partials/checklist-summary.vue'
|
||||
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
|
||||
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
|
||||
|
||||
const {
|
||||
task,
|
||||
showProject = false,
|
||||
} = defineProps<{
|
||||
task: ITask,
|
||||
showProject?: boolean,
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const project = computed(() => projectStore.projects[task.projectId])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
transition: background-color $transition;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: $radius;
|
||||
border: 2px solid transparent;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
//display: -webkit-box;
|
||||
hyphens: auto;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
//flex: 1 0 50%;
|
||||
|
||||
.dueDate {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.overdue {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.task-project {
|
||||
width: auto;
|
||||
color: var(--grey-400);
|
||||
font-size: .9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
vertical-align: bottom;
|
||||
margin-left: .5rem;
|
||||
height: 21px;
|
||||
width: 21px;
|
||||
}
|
||||
|
||||
.project-task-icon {
|
||||
margin-left: 6px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text);
|
||||
transition: color ease $transition-duration;
|
||||
|
||||
&:hover {
|
||||
color: var(--grey-900);
|
||||
}
|
||||
}
|
||||
|
||||
.tasktext.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
|
||||
span.parent-tasks {
|
||||
color: var(--grey-500);
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
31
frontend/src/components/tasks/partials/sort.vue
Normal file
31
frontend/src/components/tasks/partials/sort.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<BaseButton>
|
||||
<icon
|
||||
v-if="order === 'asc'"
|
||||
icon="sort-up"
|
||||
/>
|
||||
<icon
|
||||
v-else-if="order === 'desc'"
|
||||
icon="sort-up"
|
||||
rotation="180"
|
||||
/>
|
||||
<icon
|
||||
v-else
|
||||
icon="sort"
|
||||
/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
type Order = 'asc' | 'desc' | 'none'
|
||||
|
||||
defineProps({
|
||||
order: {
|
||||
type: String as PropType<Order>,
|
||||
default: 'none',
|
||||
},
|
||||
})
|
||||
</script>
|
Reference in New Issue
Block a user