feat: quick actions improvments
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/3728
This commit is contained in:
commit
47d589002c
@ -24,7 +24,7 @@
|
||||
{{ hintText }}
|
||||
</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 v-for="(r, k) in results" :key="k" class="result">
|
||||
@ -44,7 +44,18 @@
|
||||
@keyup.prevent.enter="doAction(r.type, i)"
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
@ -66,6 +77,8 @@ import ProjectModel from '@/models/project'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.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 {useProjectStore} from '@/stores/projects'
|
||||
@ -97,6 +110,7 @@ enum ACTION_TYPE {
|
||||
TASK = 'task',
|
||||
PROJECT = 'project',
|
||||
TEAM = 'team',
|
||||
LABELS = 'labels',
|
||||
}
|
||||
|
||||
enum COMMAND_TYPE {
|
||||
@ -134,24 +148,38 @@ function closeQuickActions() {
|
||||
}
|
||||
|
||||
const foundProjects = computed(() => {
|
||||
const { project } = parsedQuery.value
|
||||
if (
|
||||
searchMode.value === SEARCH_MODE.ALL ||
|
||||
searchMode.value === SEARCH_MODE.PROJECTS ||
|
||||
project === null
|
||||
) {
|
||||
const {project, text, labels, assignees} = parsedQuery.value
|
||||
|
||||
if (project !== null) {
|
||||
return projectStore.searchProject(project ?? text)
|
||||
.filter(p => Boolean(p))
|
||||
}
|
||||
|
||||
if (labels.length > 0 || assignees.length > 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const history = getHistory()
|
||||
const allProjects = [
|
||||
...new Set([
|
||||
...history.map((l) => projectStore.projects[l.id]),
|
||||
...projectStore.searchProject(project),
|
||||
]),
|
||||
]
|
||||
if (text === '') {
|
||||
const history = getHistory()
|
||||
return history.map((p) => projectStore.projects[p.id])
|
||||
.filter(p => Boolean(p))
|
||||
}
|
||||
|
||||
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
|
||||
@ -172,15 +200,20 @@ const results = computed<Result[]>(() => {
|
||||
title: t('quickActions.commands'),
|
||||
items: foundCommands.value,
|
||||
},
|
||||
{
|
||||
type: ACTION_TYPE.PROJECT,
|
||||
title: t('quickActions.projects'),
|
||||
items: foundProjects.value,
|
||||
},
|
||||
{
|
||||
type: ACTION_TYPE.TASK,
|
||||
title: t('quickActions.tasks'),
|
||||
items: foundTasks.value,
|
||||
},
|
||||
{
|
||||
type: ACTION_TYPE.PROJECT,
|
||||
title: t('quickActions.projects'),
|
||||
items: foundProjects.value,
|
||||
type: ACTION_TYPE.LABELS,
|
||||
title: t('quickActions.labels'),
|
||||
items: foundLabels.value,
|
||||
},
|
||||
{
|
||||
type: ACTION_TYPE.TEAM,
|
||||
@ -190,7 +223,7 @@ const results = computed<Result[]>(() => {
|
||||
].filter((i) => i.items.length > 0)
|
||||
})
|
||||
|
||||
const loading = computed(() =>
|
||||
const loading = computed(() =>
|
||||
taskService.loading ||
|
||||
projectStore.isLoading ||
|
||||
teamService.loading,
|
||||
@ -262,10 +295,12 @@ const searchMode = computed(() => {
|
||||
if (query.value === '') {
|
||||
return SEARCH_MODE.ALL
|
||||
}
|
||||
const { text, project, labels, assignees } = parsedQuery.value
|
||||
|
||||
const {text, project, labels, assignees} = parsedQuery.value
|
||||
if (assignees.length === 0 && text !== '') {
|
||||
return SEARCH_MODE.TASKS
|
||||
}
|
||||
|
||||
if (
|
||||
assignees.length === 0 &&
|
||||
project !== null &&
|
||||
@ -274,6 +309,7 @@ const searchMode = computed(() => {
|
||||
) {
|
||||
return SEARCH_MODE.PROJECTS
|
||||
}
|
||||
|
||||
if (
|
||||
assignees.length > 0 &&
|
||||
project === null &&
|
||||
@ -282,6 +318,7 @@ const searchMode = computed(() => {
|
||||
) {
|
||||
return SEARCH_MODE.TEAMS
|
||||
}
|
||||
|
||||
return SEARCH_MODE.ALL
|
||||
})
|
||||
|
||||
@ -292,12 +329,12 @@ const isNewTaskCommand = computed(() => (
|
||||
|
||||
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[]) {
|
||||
const filter_by : Filter['by'][] = []
|
||||
const filter_value : Filter['value'][] = []
|
||||
const filter_comparator : Filter['comparator'][] = []
|
||||
const filter_by: Filter['by'][] = []
|
||||
const filter_value: Filter['value'][] = []
|
||||
const filter_comparator: Filter['comparator'][] = []
|
||||
|
||||
filters.forEach(({by, value, comparator}) => {
|
||||
filter_by.push(by)
|
||||
@ -315,7 +352,8 @@ function filtersToParams(filters: Filter[]) {
|
||||
function searchTasks() {
|
||||
if (
|
||||
searchMode.value !== SEARCH_MODE.ALL &&
|
||||
searchMode.value !== SEARCH_MODE.TASKS
|
||||
searchMode.value !== SEARCH_MODE.TASKS &&
|
||||
searchMode.value !== SEARCH_MODE.PROJECTS
|
||||
) {
|
||||
foundTasks.value = []
|
||||
return
|
||||
@ -330,7 +368,7 @@ function searchTasks() {
|
||||
taskSearchTimeout.value = null
|
||||
}
|
||||
|
||||
const { text, project: projectName, labels } = parsedQuery.value
|
||||
const {text, project: projectName, labels} = parsedQuery.value
|
||||
|
||||
const filters: Filter[] = []
|
||||
|
||||
@ -349,8 +387,9 @@ function searchTasks() {
|
||||
|
||||
if (projectName !== null) {
|
||||
const project = projectStore.findProjectByExactname(projectName)
|
||||
console.log({project})
|
||||
if (project !== null) {
|
||||
addFilter('projectId', project.id, 'equals')
|
||||
addFilter('project_id', project.id, 'equals')
|
||||
}
|
||||
}
|
||||
|
||||
@ -361,19 +400,16 @@ function searchTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const params = {
|
||||
s: text,
|
||||
...filtersToParams(filters),
|
||||
}
|
||||
const params = {
|
||||
s: text,
|
||||
sort_by: 'done',
|
||||
...filtersToParams(filters),
|
||||
}
|
||||
|
||||
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) => {
|
||||
t.type = ACTION_TYPE.TASK
|
||||
const project = projectStore.projects[t.projectId]
|
||||
if (project !== null) {
|
||||
t.title = `${t.title} (${project.title})`
|
||||
}
|
||||
return t
|
||||
})
|
||||
}, 150)
|
||||
@ -396,10 +432,10 @@ function searchTeams() {
|
||||
clearTimeout(teamSearchTimeout.value)
|
||||
teamSearchTimeout.value = null
|
||||
}
|
||||
const { assignees } = parsedQuery.value
|
||||
const {assignees} = parsedQuery.value
|
||||
teamSearchTimeout.value = setTimeout(async () => {
|
||||
const teamSearchPromises = assignees.map((t) =>
|
||||
teamService.getAll({}, { s: t }),
|
||||
teamService.getAll({}, {s: t}),
|
||||
)
|
||||
const teamsResult = await Promise.all(teamSearchPromises)
|
||||
foundTeams.value = teamsResult.flat().map((team) => {
|
||||
@ -422,21 +458,21 @@ async function doAction(type: ACTION_TYPE, item: DoAction) {
|
||||
closeQuickActions()
|
||||
await router.push({
|
||||
name: 'project.index',
|
||||
params: { projectId: (item as DoAction<IProject>).id },
|
||||
params: {projectId: (item as DoAction<IProject>).id},
|
||||
})
|
||||
break
|
||||
case ACTION_TYPE.TASK:
|
||||
closeQuickActions()
|
||||
await router.push({
|
||||
name: 'task.detail',
|
||||
params: { id: (item as DoAction<ITask>).id },
|
||||
params: {id: (item as DoAction<ITask>).id},
|
||||
})
|
||||
break
|
||||
case ACTION_TYPE.TEAM:
|
||||
closeQuickActions()
|
||||
await router.push({
|
||||
name: 'teams.edit',
|
||||
params: { id: (item as DoAction<ITeam>).id },
|
||||
params: {id: (item as DoAction<ITeam>).id},
|
||||
})
|
||||
break
|
||||
case ACTION_TYPE.CMD:
|
||||
@ -444,6 +480,11 @@ async function doAction(type: ACTION_TYPE, item: DoAction) {
|
||||
selectedCmd.value = item as DoAction<Command>
|
||||
searchInput.value?.focus()
|
||||
break
|
||||
case ACTION_TYPE.LABELS:
|
||||
query.value = '*' + item.title
|
||||
searchInput.value?.focus()
|
||||
searchTasks()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -470,8 +511,8 @@ async function newTask() {
|
||||
title: query.value,
|
||||
projectId: currentProject.value.id,
|
||||
})
|
||||
success({ message: t('task.createSuccess') })
|
||||
await router.push({ name: 'task.detail', params: { id: task.id } })
|
||||
success({message: t('task.createSuccess')})
|
||||
await router.push({name: 'task.detail', params: {id: task.id}})
|
||||
}
|
||||
|
||||
async function newProject() {
|
||||
@ -481,17 +522,17 @@ async function newProject() {
|
||||
await projectStore.createProject(new ProjectModel({
|
||||
title: query.value,
|
||||
}))
|
||||
success({ message: t('project.create.createdSuccess')})
|
||||
success({message: t('project.create.createdSuccess')})
|
||||
}
|
||||
|
||||
async function newTeam() {
|
||||
const newTeam = new TeamModel({ name: query.value })
|
||||
const newTeam = new TeamModel({name: query.value})
|
||||
const team = await teamService.create(newTeam)
|
||||
await router.push({
|
||||
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>
|
||||
@ -502,7 +543,7 @@ function setResultRefs(el: Element | ComponentPublicInstance | null, index: numb
|
||||
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) {
|
||||
@ -547,7 +588,7 @@ function reset() {
|
||||
<style lang="scss" scoped>
|
||||
.quick-actions {
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
// FIXME: changed position should be an option of the modal
|
||||
:deep(.modal-content) {
|
||||
top: 3rem;
|
||||
@ -569,6 +610,7 @@ function reset() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.active-cmd {
|
||||
font-size: 1.25rem;
|
||||
margin-left: .5rem;
|
||||
@ -614,10 +656,4 @@ function reset() {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
}
|
||||
|
||||
// HACK:
|
||||
// FIXME:
|
||||
.modal-container-smaller :deep(.hint-modal .modal-container) {
|
||||
height: calc(100vh - 5rem);
|
||||
}
|
||||
</style>
|
25
src/components/tasks/partials/label.vue
Normal file
25
src/components/tasks/partials/label.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
|
||||
defineProps<{
|
||||
label: ILabel
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:key="label.id"
|
||||
:style="{'background': label.hexColor, 'color': label.textColor}"
|
||||
class="tag"
|
||||
>
|
||||
<span>{{ label.title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tag {
|
||||
& + & {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<div class="label-wrapper">
|
||||
<span
|
||||
<XLabel
|
||||
v-for="label in labels"
|
||||
:label="label"
|
||||
:key="label.id"
|
||||
:style="{'background': label.hexColor, 'color': label.textColor}"
|
||||
class="tag"
|
||||
v-for="label in labels">
|
||||
<span>{{ label.title }}</span>
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -14,6 +12,8 @@
|
||||
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[]>,
|
||||
@ -26,10 +26,4 @@ defineProps({
|
||||
.label-wrapper {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.tag {
|
||||
& + & {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -108,7 +108,7 @@ const visible = ref(false)
|
||||
const mode = computed(() => authStore.settings.frontendSettings.quickAddMagicMode)
|
||||
|
||||
defineProps<{
|
||||
highlightHintIcon: boolean,
|
||||
highlightHintIcon?: boolean,
|
||||
}>()
|
||||
|
||||
const prefixes = computed(() => PREFIXES[mode.value])
|
||||
|
192
src/components/tasks/partials/singleTaskInlineReadonly.vue
Normal file
192
src/components/tasks/partials/singleTaskInlineReadonly.vue
Normal 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">, </template>
|
||||
</template>
|
||||
›
|
||||
</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>
|
@ -905,6 +905,7 @@
|
||||
"tasks": "Tasks",
|
||||
"projects": "Projects",
|
||||
"teams": "Teams",
|
||||
"labels": "Labels",
|
||||
"newProject": "Enter the title of the new project…",
|
||||
"newTask": "Enter the title of the new task…",
|
||||
"newTeam": "Enter the name of the new team…",
|
||||
|
@ -19,7 +19,7 @@ import {success} from '@/message'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
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 {
|
||||
[id: IProject['id']]: IProject
|
||||
@ -174,6 +174,7 @@ export const useProjectStore = defineStore('project', () => {
|
||||
const loadedProjects = await projectService.getAll({}, {is_archived: true}) as IProject[]
|
||||
projects.value = {}
|
||||
setProjects(loadedProjects)
|
||||
loadedProjects.forEach(p => add(p))
|
||||
|
||||
return loadedProjects
|
||||
} finally {
|
||||
|
Loading…
x
Reference in New Issue
Block a user