Compare commits

...
This repository has been archived on 2025-10-28. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.

12 commits

10 changed files with 253 additions and 37 deletions

View file

@ -1,8 +1,10 @@
<template> <template>
<div class="datepicker"> <div class="datepicker">
<BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined"> <slot :date="date" :openPopup="toggleDatePopup">
{{ date === null ? chooseDateLabel : formatDateShort(date) }} <BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
</BaseButton> {{ date === null ? chooseDateLabel : formatDateShort(date) }}
</BaseButton>
</slot>
<transition name="fade"> <transition name="fade">
<div v-if="show" class="datepicker-popup" ref="datepickerPopup"> <div v-if="show" class="datepicker-popup" ref="datepickerPopup">

View file

@ -9,7 +9,7 @@
@close="$router.back()" @close="$router.back()"
:loading="loading" :loading="loading"
> >
<div class="p-4"> <div :class="{'p-4': padding}">
<slot /> <slot />
</div> </div>
@ -72,6 +72,10 @@ defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
padding: {
type: Boolean,
default: true,
},
}) })
const emit = defineEmits(['create', 'primary', 'tertiary']) const emit = defineEmits(['create', 'primary', 'tertiary'])

View file

@ -186,7 +186,7 @@ export default defineComponent({
if (this.selectedCmd !== null) { if (this.selectedCmd !== null) {
switch (this.selectedCmd.action) { switch (this.selectedCmd.action) {
case CMD_NEW_TASK: case CMD_NEW_TASK:
return this.$t('quickActions.newTask') return this.$t('task.create.title')
case CMD_NEW_LIST: case CMD_NEW_LIST:
return this.$t('quickActions.newList') return this.$t('quickActions.newList')
case CMD_NEW_NAMESPACE: case CMD_NEW_NAMESPACE:

View file

@ -0,0 +1,23 @@
import UserService from '@/services/user'
import {findPropertyByValue} from '@/helpers/findPropertyByValue'
// Check if the user exists
function validateUsername(users: IUser[], username: IUser['username']) {
return findPropertyByValue(users, 'username', username)
}
export async function findAssignees(parsedTaskAssignees: string[]) {
if (parsedTaskAssignees.length <= 0) {
return []
}
const userService = new UserService()
const assignees = parsedTaskAssignees.map(async a => {
const users = await userService.getAll({}, {s: a})
return validateUsername(users, a)
})
const validatedUsers = await Promise.all(assignees)
return validatedUsers.filter((item) => Boolean(item))
}

View file

@ -0,0 +1,7 @@
// IDEA: maybe use a small fuzzy search here to prevent errors
export function findPropertyByValue(object, key, value) {
return Object.values(object).find(
(l) => l[key]?.toLowerCase() === value.toLowerCase(),
)
}

View file

@ -616,6 +616,12 @@
"select": "Select a date range", "select": "Select a date range",
"noTasks": "Nothing to do — Have a nice day!" "noTasks": "Nothing to do — Have a nice day!"
}, },
"create": {
"heading": "Create a task in {list}",
"title": "Enter the title of the new task…",
"descriptionPlaceholder": "Enter your description here…",
"description": "Add description…"
},
"detail": { "detail": {
"chooseDueDate": "Click here to set a due date", "chooseDueDate": "Click here to set a due date",
"chooseStartDate": "Click here to set a start date", "chooseStartDate": "Click here to set a start date",
@ -933,7 +939,6 @@
"lists": "Lists", "lists": "Lists",
"teams": "Teams", "teams": "Teams",
"newList": "Enter the title of the new list…", "newList": "Enter the title of the new list…",
"newTask": "Enter the title of the new task…",
"newNamespace": "Enter the title of the new namespace…", "newNamespace": "Enter the title of the new namespace…",
"newTeam": "Enter the name of the new team…", "newTeam": "Enter the name of the new team…",
"createTask": "Create a task in the current list ({title})", "createTask": "Create a task in the current list ({title})",

View file

@ -23,6 +23,7 @@ import UpcomingTasksComponent from '../views/tasks/ShowTasks.vue'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue' import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
import ListNamespaces from '../views/namespaces/ListNamespaces.vue' import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
import TaskDetailView from '../views/tasks/TaskDetailView.vue' import TaskDetailView from '../views/tasks/TaskDetailView.vue'
import CreateTask from '../views/tasks/CreateTask.vue'
// Team Handling // Team Handling
import ListTeamsComponent from '../views/teams/ListTeams.vue' import ListTeamsComponent from '../views/teams/ListTeams.vue'
// Label Handling // Label Handling
@ -348,6 +349,15 @@ const router = createRouter({
}, },
props: route => ({ listId: Number(route.params.listId as string) }), props: route => ({ listId: Number(route.params.listId as string) }),
}, },
{
path: '/lists/:listId/create-task',
name: 'task.create',
component: CreateTask,
meta: {
showAsModal: true,
},
props: route => ({ listId: Number(route.params.listId as string) }),
},
{ {
path: '/lists/:listId', path: '/lists/:listId',
name: 'list.index', name: 'list.index',

View file

@ -51,8 +51,8 @@ export default class TaskService extends AbstractService<ITask> {
model.startDate = parseDate(model.startDate) model.startDate = parseDate(model.startDate)
model.endDate = parseDate(model.endDate) model.endDate = parseDate(model.endDate)
model.doneAt = parseDate(model.doneAt) model.doneAt = parseDate(model.doneAt)
model.created = formatISO(new Date(model.created)) model.created = +(model.created) ? parseDate(model.created) : null
model.updated = formatISO(new Date(model.updated)) model.updated = +(model.updated) ? parseDate(model.updated) : null
// remove all nulls, these would create empty reminders // remove all nulls, these would create empty reminders
for (const index in model.reminderDates) { for (const index in model.reminderDates) {

View file

@ -5,7 +5,6 @@ import {formatISO} from 'date-fns'
import TaskService from '@/services/task' import TaskService from '@/services/task'
import TaskAssigneeService from '@/services/taskAssignee' import TaskAssigneeService from '@/services/taskAssignee'
import LabelTaskService from '@/services/labelTask' import LabelTaskService from '@/services/labelTask'
import UserService from '@/services/user'
import {HAS_TASKS} from '../mutation-types' import {HAS_TASKS} from '../mutation-types'
import {setLoading} from '../helper' import {setLoading} from '../helper'
@ -28,18 +27,8 @@ import type { RootStoreState, TaskState } from '@/store/types'
import {useLabelStore} from '@/stores/labels' import {useLabelStore} from '@/stores/labels'
import {useListStore} from '@/stores/lists' import {useListStore} from '@/stores/lists'
import {playPop} from '@/helpers/playPop' import {playPop} from '@/helpers/playPop'
import {findPropertyByValue} from '@/helpers/findPropertyByValue'
// IDEA: maybe use a small fuzzy search here to prevent errors import {findAssignees} from '@/helpers/findAssignees'
function findPropertyByValue(object, key, value) {
return Object.values(object).find(
(l) => l[key]?.toLowerCase() === value.toLowerCase(),
)
}
// Check if the user exists
function validateUsername(users: IUser[], username: IUser['username']) {
return findPropertyByValue(users, 'username', username)
}
// Check if the label exists // Check if the label exists
function validateLabel(labels: ILabel[], label: ILabel) { function validateLabel(labels: ILabel[], label: ILabel) {
@ -57,22 +46,6 @@ async function addLabelToTask(task: ITask, label: ILabel) {
return response return response
} }
async function findAssignees(parsedTaskAssignees) {
if (parsedTaskAssignees.length <= 0) {
return []
}
const userService = new UserService()
const assignees = parsedTaskAssignees.map(async a => {
const users = await userService.getAll({}, {s: a})
return validateUsername(users, a)
})
const validatedUsers = await Promise.all(assignees)
return validatedUsers.filter((item) => Boolean(item))
}
const tasksStore : Module<TaskState, RootStoreState>= { const tasksStore : Module<TaskState, RootStoreState>= {
namespaced: true, namespaced: true,
state: () => ({}), state: () => ({}),

View file

@ -0,0 +1,192 @@
<template>
<CreateEdit
:padding="false"
:title="heading"
@primary="create"
:loading="taskService.loading"
>
<input
:placeholder="$t('task.create.title')"
class="task-title input"
@keyup.enter="create"
v-model="newTask.title"
v-focus
/>
<p class="help is-danger ml-4" v-if="errorMessage !== ''">
{{ errorMessage }}
</p>
<QuickAddMagic class="ml-4" v-else/>
<BaseButton
v-if="!descriptionFormVisible"
@click="() => descriptionFormVisible = true"
class="toggle-description-button"
>
{{ $t('task.create.description') }}
</BaseButton>
<editor
v-if="descriptionFormVisible"
v-model="newTask.description"
:placeholder="$t('task.create.descriptionPlaceholder')"
:preview-is-default="false"
class="m-4"
/>
<div class="px-4 pb-4 task-attributes">
<Datepicker
v-model="newTask.dueDate"
v-slot="{ date, openPopup }"
>
<XButton variant="secondary" @click.stop="openPopup()" class="datepicker-button">
{{ date ? formatDateShort(date) : t('task.attributes.dueDate') }}
</XButton>
</Datepicker>
<XButton variant="secondary" class="ml-2">
{{ t('task.detail.actions.label') }}
</XButton>
<div class="is-flex pl-2">
<span
v-for="label in realLabels"
:style="{'background': label.hexColor, 'color': label.textColor}"
class="tag mr-2">
<span>{{ label.title }}</span>
<BaseButton @click="removeLabel(label)" class="delete is-small"/>
</span>
</div>
</div>
</CreateEdit>
</template>
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {formatISO} from 'date-fns'
import CreateEdit from '@/components/misc/create-edit.vue'
import Editor from '@/components/input/AsyncEditor'
import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import XButton from '@/components/input/button.vue'
import Datepicker from '@/components/input/datepicker.vue'
import type {ITask} from '@/modelTypes/ITask'
import TaskModel from '@/models/task'
import TaskService from '@/services/task'
import {useRouter} from 'vue-router'
import {useListStore} from '@/stores/lists'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {parseTaskText} from '@/modules/parseTaskText'
import {findAssignees} from '@/helpers/findAssignees'
import {formatDateShort} from '@/helpers/time/formatDate'
import {useLabelStore} from '@/stores/labels'
import {useStore} from '@/store'
import type {ILabel} from '@/modelTypes/ILabel'
import LabelModel from '@/models/label'
const listStore = useListStore()
const labelStore = useLabelStore()
const store = useStore()
const router = useRouter()
const {t} = useI18n()
const props = defineProps<{
listId: number,
}>()
const heading = computed(() => {
const listTitle = listStore.getListById(props.listId)?.title || ''
return listTitle !== ''
? t('task.create.heading', {list: listTitle})
: t('task.new')
})
const errorMessage = ref('')
const taskService = ref(new TaskService())
const descriptionFormVisible = ref(false)
const newTask = ref<ITask>(new TaskModel({}))
const parsedTask = computed(() => parseTaskText(newTask.value.title, getQuickAddMagicMode()))
watch(
() => parsedTask.value.date,
date => newTask.value.dueDate = date,
)
const labels = ref<string[]>([])
watch(
() => parsedTask.value.labels,
labelTitles => labels.value = labelTitles,
)
const realLabels = computed<ILabel[]>(() => {
const existingLabels = labelStore.getLabelsByExactTitles(labels.value)
const newLabels = labels.value
.filter(l => l !== '' && !(existingLabels.map(le => le.title).includes(l)))
.map(newLabel => new LabelModel({title: newLabel}))
return [
...existingLabels,
...newLabels,
]
})
function removeLabel(label: ILabel) {
while (true) { // Using a loop to remove all labels, including possible duplicates added via quick add magic
const index = labels.value.findIndex(el => el.toLowerCase() === label.title.toLowerCase())
if (index === -1) {
break
}
labels.value.splice(index, 1)
}
}
async function create() {
if (newTask.value.title === '') {
errorMessage.value = t('list.create.addTitleRequired')
return
}
errorMessage.value = ''
const assignees = await findAssignees(parsedTask.value.assignees)
const finalTask = new TaskModel({
...newTask.value,
listId: props.listId,
title: parsedTask.value.text,
dueDate: newTask.value.dueDate !== null ? formatISO(newTask.value.dueDate) : null,
priority: parsedTask.value.priority,
assignees: parsedTask.value.assignees,
})
const task = await taskService.value.create(finalTask)
await store.dispatch('tasks/addLabelsToTask', {
task,
parsedLabels: labels.value,
})
return router.push({name: 'task.detail', params: {id: task.id}})
}
</script>
<style scoped>
.task-title {
width: 100%;
font-size: 1.5rem;
border: 0;
padding: 1rem;
}
.toggle-description-button {
padding: 1rem;
color: var(--grey-400);
width: 100%;
text-align: left;
}
.datepicker-button {
white-space: nowrap;
}
.task-attributes {
display: flex;
align-items: center;
overflow-y: auto;
}
</style>