Compare commits

...

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>