1
0

chore: move frontend files

This commit is contained in:
kolaente
2024-02-07 14:56:56 +01:00
parent 447641c222
commit fc4676315d
606 changed files with 0 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>&nbsp;
<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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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(' &gt; ') }} &gt;
</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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">,&nbsp;</template>
</template>
&rsaquo;
</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>

View 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>