feat: quick actions improvments

Reviewed-on: vikunja/frontend#3728
This commit is contained in:
konrad 2023-08-29 11:24:00 +00:00
commit 47d589002c
7 changed files with 319 additions and 70 deletions

View File

@ -24,7 +24,7 @@
{{ hintText }} {{ hintText }}
</div> </div>
<quick-add-magic class="p-2 modal-container-smaller" v-if="isNewTaskCommand"/> <quick-add-magic v-if="isNewTaskCommand"/>
<div class="results" v-if="selectedCmd === null"> <div class="results" v-if="selectedCmd === null">
<div v-for="(r, k) in results" :key="k" class="result"> <div v-for="(r, k) in results" :key="k" class="result">
@ -44,7 +44,18 @@
@keyup.prevent.enter="doAction(r.type, i)" @keyup.prevent.enter="doAction(r.type, i)"
@keyup.prevent.esc="searchInput?.focus()" @keyup.prevent.esc="searchInput?.focus()"
> >
{{ i.title }} <template v-if="r.type === ACTION_TYPE.LABELS">
<x-label :label="i"/>
</template>
<template v-else-if="r.type === ACTION_TYPE.TASK">
<single-task-inline-readonly
:task="i"
:show-project="true"
/>
</template>
<template v-else>
{{ i.title }}
</template>
</BaseButton> </BaseButton>
</div> </div>
</div> </div>
@ -66,6 +77,8 @@ import ProjectModel from '@/models/project'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue' import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import XLabel from '@/components/tasks/partials/label.vue'
import SingleTaskInlineReadonly from '@/components/tasks/partials/singleTaskInlineReadonly.vue'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects' import {useProjectStore} from '@/stores/projects'
@ -97,6 +110,7 @@ enum ACTION_TYPE {
TASK = 'task', TASK = 'task',
PROJECT = 'project', PROJECT = 'project',
TEAM = 'team', TEAM = 'team',
LABELS = 'labels',
} }
enum COMMAND_TYPE { enum COMMAND_TYPE {
@ -134,24 +148,38 @@ function closeQuickActions() {
} }
const foundProjects = computed(() => { const foundProjects = computed(() => {
const { project } = parsedQuery.value const {project, text, labels, assignees} = parsedQuery.value
if (
searchMode.value === SEARCH_MODE.ALL || if (project !== null) {
searchMode.value === SEARCH_MODE.PROJECTS || return projectStore.searchProject(project ?? text)
project === null .filter(p => Boolean(p))
) { }
if (labels.length > 0 || assignees.length > 0) {
return [] return []
} }
const history = getHistory() if (text === '') {
const allProjects = [ const history = getHistory()
...new Set([ return history.map((p) => projectStore.projects[p.id])
...history.map((l) => projectStore.projects[l.id]), .filter(p => Boolean(p))
...projectStore.searchProject(project), }
]),
]
return allProjects.filter(l => Boolean(l)) return projectStore.searchProject(project ?? text)
.filter(p => Boolean(p))
})
const foundLabels = computed(() => {
const {labels, text} = parsedQuery.value
if (text === '' && labels.length === 0) {
return []
}
if (labels.length > 0) {
return labelStore.filterLabelsByQuery([], labels[0])
}
return labelStore.filterLabelsByQuery([], text)
}) })
// FIXME: use fuzzysearch // FIXME: use fuzzysearch
@ -172,15 +200,20 @@ const results = computed<Result[]>(() => {
title: t('quickActions.commands'), title: t('quickActions.commands'),
items: foundCommands.value, items: foundCommands.value,
}, },
{
type: ACTION_TYPE.PROJECT,
title: t('quickActions.projects'),
items: foundProjects.value,
},
{ {
type: ACTION_TYPE.TASK, type: ACTION_TYPE.TASK,
title: t('quickActions.tasks'), title: t('quickActions.tasks'),
items: foundTasks.value, items: foundTasks.value,
}, },
{ {
type: ACTION_TYPE.PROJECT, type: ACTION_TYPE.LABELS,
title: t('quickActions.projects'), title: t('quickActions.labels'),
items: foundProjects.value, items: foundLabels.value,
}, },
{ {
type: ACTION_TYPE.TEAM, type: ACTION_TYPE.TEAM,
@ -190,7 +223,7 @@ const results = computed<Result[]>(() => {
].filter((i) => i.items.length > 0) ].filter((i) => i.items.length > 0)
}) })
const loading = computed(() => const loading = computed(() =>
taskService.loading || taskService.loading ||
projectStore.isLoading || projectStore.isLoading ||
teamService.loading, teamService.loading,
@ -262,10 +295,12 @@ const searchMode = computed(() => {
if (query.value === '') { if (query.value === '') {
return SEARCH_MODE.ALL return SEARCH_MODE.ALL
} }
const { text, project, labels, assignees } = parsedQuery.value
const {text, project, labels, assignees} = parsedQuery.value
if (assignees.length === 0 && text !== '') { if (assignees.length === 0 && text !== '') {
return SEARCH_MODE.TASKS return SEARCH_MODE.TASKS
} }
if ( if (
assignees.length === 0 && assignees.length === 0 &&
project !== null && project !== null &&
@ -274,6 +309,7 @@ const searchMode = computed(() => {
) { ) {
return SEARCH_MODE.PROJECTS return SEARCH_MODE.PROJECTS
} }
if ( if (
assignees.length > 0 && assignees.length > 0 &&
project === null && project === null &&
@ -282,6 +318,7 @@ const searchMode = computed(() => {
) { ) {
return SEARCH_MODE.TEAMS return SEARCH_MODE.TEAMS
} }
return SEARCH_MODE.ALL return SEARCH_MODE.ALL
}) })
@ -292,12 +329,12 @@ const isNewTaskCommand = computed(() => (
const taskSearchTimeout = ref<ReturnType<typeof setTimeout> | null>(null) const taskSearchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
type Filter = {by: string, value: string | number, comparator: string} type Filter = { by: string, value: string | number, comparator: string }
function filtersToParams(filters: Filter[]) { function filtersToParams(filters: Filter[]) {
const filter_by : Filter['by'][] = [] const filter_by: Filter['by'][] = []
const filter_value : Filter['value'][] = [] const filter_value: Filter['value'][] = []
const filter_comparator : Filter['comparator'][] = [] const filter_comparator: Filter['comparator'][] = []
filters.forEach(({by, value, comparator}) => { filters.forEach(({by, value, comparator}) => {
filter_by.push(by) filter_by.push(by)
@ -315,7 +352,8 @@ function filtersToParams(filters: Filter[]) {
function searchTasks() { function searchTasks() {
if ( if (
searchMode.value !== SEARCH_MODE.ALL && searchMode.value !== SEARCH_MODE.ALL &&
searchMode.value !== SEARCH_MODE.TASKS searchMode.value !== SEARCH_MODE.TASKS &&
searchMode.value !== SEARCH_MODE.PROJECTS
) { ) {
foundTasks.value = [] foundTasks.value = []
return return
@ -330,7 +368,7 @@ function searchTasks() {
taskSearchTimeout.value = null taskSearchTimeout.value = null
} }
const { text, project: projectName, labels } = parsedQuery.value const {text, project: projectName, labels} = parsedQuery.value
const filters: Filter[] = [] const filters: Filter[] = []
@ -349,8 +387,9 @@ function searchTasks() {
if (projectName !== null) { if (projectName !== null) {
const project = projectStore.findProjectByExactname(projectName) const project = projectStore.findProjectByExactname(projectName)
console.log({project})
if (project !== null) { if (project !== null) {
addFilter('projectId', project.id, 'equals') addFilter('project_id', project.id, 'equals')
} }
} }
@ -361,19 +400,16 @@ function searchTasks() {
} }
} }
const params = { const params = {
s: text, s: text,
...filtersToParams(filters), sort_by: 'done',
} ...filtersToParams(filters),
}
taskSearchTimeout.value = setTimeout(async () => { taskSearchTimeout.value = setTimeout(async () => {
const r = await taskService.getAll({}, params) as DoAction<ITask>[] const r = await taskService.getAll({}, params) as DoAction<ITask>[]
foundTasks.value = r.map((t) => { foundTasks.value = r.map((t) => {
t.type = ACTION_TYPE.TASK t.type = ACTION_TYPE.TASK
const project = projectStore.projects[t.projectId]
if (project !== null) {
t.title = `${t.title} (${project.title})`
}
return t return t
}) })
}, 150) }, 150)
@ -396,10 +432,10 @@ function searchTeams() {
clearTimeout(teamSearchTimeout.value) clearTimeout(teamSearchTimeout.value)
teamSearchTimeout.value = null teamSearchTimeout.value = null
} }
const { assignees } = parsedQuery.value const {assignees} = parsedQuery.value
teamSearchTimeout.value = setTimeout(async () => { teamSearchTimeout.value = setTimeout(async () => {
const teamSearchPromises = assignees.map((t) => const teamSearchPromises = assignees.map((t) =>
teamService.getAll({}, { s: t }), teamService.getAll({}, {s: t}),
) )
const teamsResult = await Promise.all(teamSearchPromises) const teamsResult = await Promise.all(teamSearchPromises)
foundTeams.value = teamsResult.flat().map((team) => { foundTeams.value = teamsResult.flat().map((team) => {
@ -422,21 +458,21 @@ async function doAction(type: ACTION_TYPE, item: DoAction) {
closeQuickActions() closeQuickActions()
await router.push({ await router.push({
name: 'project.index', name: 'project.index',
params: { projectId: (item as DoAction<IProject>).id }, params: {projectId: (item as DoAction<IProject>).id},
}) })
break break
case ACTION_TYPE.TASK: case ACTION_TYPE.TASK:
closeQuickActions() closeQuickActions()
await router.push({ await router.push({
name: 'task.detail', name: 'task.detail',
params: { id: (item as DoAction<ITask>).id }, params: {id: (item as DoAction<ITask>).id},
}) })
break break
case ACTION_TYPE.TEAM: case ACTION_TYPE.TEAM:
closeQuickActions() closeQuickActions()
await router.push({ await router.push({
name: 'teams.edit', name: 'teams.edit',
params: { id: (item as DoAction<ITeam>).id }, params: {id: (item as DoAction<ITeam>).id},
}) })
break break
case ACTION_TYPE.CMD: case ACTION_TYPE.CMD:
@ -444,6 +480,11 @@ async function doAction(type: ACTION_TYPE, item: DoAction) {
selectedCmd.value = item as DoAction<Command> selectedCmd.value = item as DoAction<Command>
searchInput.value?.focus() searchInput.value?.focus()
break break
case ACTION_TYPE.LABELS:
query.value = '*' + item.title
searchInput.value?.focus()
searchTasks()
break
} }
} }
@ -470,8 +511,8 @@ async function newTask() {
title: query.value, title: query.value,
projectId: currentProject.value.id, projectId: currentProject.value.id,
}) })
success({ message: t('task.createSuccess') }) success({message: t('task.createSuccess')})
await router.push({ name: 'task.detail', params: { id: task.id } }) await router.push({name: 'task.detail', params: {id: task.id}})
} }
async function newProject() { async function newProject() {
@ -481,17 +522,17 @@ async function newProject() {
await projectStore.createProject(new ProjectModel({ await projectStore.createProject(new ProjectModel({
title: query.value, title: query.value,
})) }))
success({ message: t('project.create.createdSuccess')}) success({message: t('project.create.createdSuccess')})
} }
async function newTeam() { async function newTeam() {
const newTeam = new TeamModel({ name: query.value }) const newTeam = new TeamModel({name: query.value})
const team = await teamService.create(newTeam) const team = await teamService.create(newTeam)
await router.push({ await router.push({
name: 'teams.edit', name: 'teams.edit',
params: { id: team.id }, params: {id: team.id},
}) })
success({ message: t('team.create.success') }) success({message: t('team.create.success')})
} }
type BaseButtonInstance = InstanceType<typeof BaseButton> type BaseButtonInstance = InstanceType<typeof BaseButton>
@ -502,7 +543,7 @@ function setResultRefs(el: Element | ComponentPublicInstance | null, index: numb
resultRefs.value[index] = [] resultRefs.value[index] = []
} }
resultRefs.value[index][key] = el as (BaseButtonInstance | null) resultRefs.value[index][key] = el as (BaseButtonInstance | null)
} }
function select(parentIndex: number, index: number) { function select(parentIndex: number, index: number) {
@ -547,7 +588,7 @@ function reset() {
<style lang="scss" scoped> <style lang="scss" scoped>
.quick-actions { .quick-actions {
overflow: hidden; overflow: hidden;
// FIXME: changed position should be an option of the modal // FIXME: changed position should be an option of the modal
:deep(.modal-content) { :deep(.modal-content) {
top: 3rem; top: 3rem;
@ -569,6 +610,7 @@ function reset() {
} }
} }
.active-cmd { .active-cmd {
font-size: 1.25rem; font-size: 1.25rem;
margin-left: .5rem; margin-left: .5rem;
@ -614,10 +656,4 @@ function reset() {
background: var(--grey-100); background: var(--grey-100);
} }
} }
// HACK:
// FIXME:
.modal-container-smaller :deep(.hint-modal .modal-container) {
height: calc(100vh - 5rem);
}
</style> </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

@ -1,12 +1,10 @@
<template> <template>
<div class="label-wrapper"> <div class="label-wrapper">
<span <XLabel
v-for="label in labels"
:label="label"
:key="label.id" :key="label.id"
:style="{'background': label.hexColor, 'color': label.textColor}" />
class="tag"
v-for="label in labels">
<span>{{ label.title }}</span>
</span>
</div> </div>
</template> </template>
@ -14,6 +12,8 @@
import type {PropType} from 'vue' import type {PropType} from 'vue'
import type {ILabel} from '@/modelTypes/ILabel' import type {ILabel} from '@/modelTypes/ILabel'
import XLabel from '@/components/tasks/partials/label.vue'
defineProps({ defineProps({
labels: { labels: {
type: Array as PropType<ILabel[]>, type: Array as PropType<ILabel[]>,
@ -26,10 +26,4 @@ defineProps({
.label-wrapper { .label-wrapper {
display: inline; display: inline;
} }
.tag {
& + & {
margin-left: 0.5rem;
}
}
</style> </style>

View File

@ -108,7 +108,7 @@ const visible = ref(false)
const mode = computed(() => authStore.settings.frontendSettings.quickAddMagicMode) const mode = computed(() => authStore.settings.frontendSettings.quickAddMagicMode)
defineProps<{ defineProps<{
highlightHintIcon: boolean, highlightHintIcon?: boolean,
}>() }>()
const prefixes = computed(() => PREFIXES[mode.value]) const prefixes = computed(() => PREFIXES[mode.value])

View File

@ -0,0 +1,192 @@
<template>
<div class="task">
<span>
<span
v-if="showProject && typeof project !== 'undefined'"
class="task-project"
:class="{'mr-2': task.hexColor !== ''}"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
>
{{ project.title }}
</span>
<ColorBubble
v-if="task.hexColor !== ''"
:color="getHexColor(task.hexColor)"
class="mr-1"
/>
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
<span class="parent-tasks" v-if="typeof task.relatedTasks?.parenttask !== 'undefined'">
<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"
/>
<User
v-for="(a, i) in task.assignees"
:avatar-size="20"
:key="task.id + 'assignee' + a.id + i"
:show-username="false"
:user="a"
class="avatar"
/>
<span
v-if="+new Date(task.dueDate) > 0"
class="dueDate"
v-tooltip="formatDateLong(task.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>
<priority-label :priority="task.priority" :done="task.done"/>
<span>
<span class="project-task-icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span class="project-task-icon" v-if="task.description">
<icon icon="align-left"/>
</span>
<span class="project-task-icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
</span>
<checklist-summary :task="task"/>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
: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 User from '@/components/misc/user.vue'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
import {useProjectStore} from '@/stores/projects'
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;
}
.progress {
margin-bottom: 0;
}
}
</style>

View File

@ -905,6 +905,7 @@
"tasks": "Tasks", "tasks": "Tasks",
"projects": "Projects", "projects": "Projects",
"teams": "Teams", "teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…", "newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…", "newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…", "newTeam": "Enter the name of the new team…",

View File

@ -19,7 +19,7 @@ import {success} from '@/message'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {getSavedFilterIdFromProjectId} from '@/services/savedFilter' import {getSavedFilterIdFromProjectId} from '@/services/savedFilter'
const {remove, search, update} = createNewIndexer('projects', ['title', 'description']) const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
export interface ProjectState { export interface ProjectState {
[id: IProject['id']]: IProject [id: IProject['id']]: IProject
@ -174,6 +174,7 @@ export const useProjectStore = defineStore('project', () => {
const loadedProjects = await projectService.getAll({}, {is_archived: true}) as IProject[] const loadedProjects = await projectService.getAll({}, {is_archived: true}) as IProject[]
projects.value = {} projects.value = {}
setProjects(loadedProjects) setProjects(loadedProjects)
loadedProjects.forEach(p => add(p))
return loadedProjects return loadedProjects
} finally { } finally {