feat: rename list to project everywhere
fix: project table view fix: e2e tests fix: typo in readme fix: list view route fix: don't wait until background is loaded for list to show fix: rename component imports fix: lint fix: parse task text fix: use list card grid fix: use correct class names fix: i18n keys fix: load project fix: task overview fix: list view spacing fix: find project fix: setLoading when updating a project fix: loading saved filter fix: project store loading fix: color picker import fix: cypress tests feat: migrate old list settings chore: add const for project settings fix: wrong projecten rename from lists chore: rename unused variable fix: editor list fix: shortcut list class name fix: pagination list class name fix: notifications list class name fix: list view variable name chore: clarify comment fix: i18n keys fix: router imports fix: comment chore: remove debugging leftover fix: remove duplicate variables fix: change comment fix: list view variable name fix: list view css class name fix: list item property name fix: name update tasks function correctly fix: update comment fix: project create route fix: list view class names fix: list view component name fix: result list class name fix: animation class list name fix: change debug log fix: revert a few navigation changes fix: use @ for imports of all views fix: rename link share list class fix: remove unused css class fix: dynamically import project components again
This commit is contained in:
@ -1,31 +1,31 @@
|
||||
<template>
|
||||
<create-edit :title="$t('list.create.header')" @create="createNewList()" :primary-disabled="list.title === ''">
|
||||
<create-edit :title="$t('project.create.header')" @create="createNewProject()" :primary-disabled="project.title === ''">
|
||||
<div class="field">
|
||||
<label class="label" for="listTitle">{{ $t('list.title') }}</label>
|
||||
<label class="label" for="projectTitle">{{ $t('project.title') }}</label>
|
||||
<div
|
||||
:class="{ 'is-loading': listService.loading }"
|
||||
:class="{ 'is-loading': projectService.loading }"
|
||||
class="control"
|
||||
>
|
||||
<input
|
||||
:class="{ disabled: listService.loading }"
|
||||
@keyup.enter="createNewList()"
|
||||
:class="{ disabled: projectService.loading }"
|
||||
@keyup.enter="createNewProject()"
|
||||
@keyup.esc="$router.back()"
|
||||
class="input"
|
||||
:placeholder="$t('list.create.titlePlaceholder')"
|
||||
:placeholder="$t('project.create.titlePlaceholder')"
|
||||
type="text"
|
||||
name="listTitle"
|
||||
name="projectTitle"
|
||||
v-focus
|
||||
v-model="list.title"
|
||||
v-model="project.title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help is-danger" v-if="showError && list.title === ''">
|
||||
{{ $t('list.create.addTitleRequired') }}
|
||||
<p class="help is-danger" v-if="showError && project.title === ''">
|
||||
{{ $t('project.create.addTitleRequired') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('list.color') }}</label>
|
||||
<label class="label">{{ $t('project.color') }}</label>
|
||||
<div class="control">
|
||||
<color-picker v-model="list.hexColor" />
|
||||
<color-picker v-model="project.hexColor" />
|
||||
</div>
|
||||
</div>
|
||||
</create-edit>
|
||||
@ -36,39 +36,39 @@ import {ref, reactive, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRouter, useRoute} from 'vue-router'
|
||||
|
||||
import ListService from '@/services/list'
|
||||
import ListModel from '@/models/list'
|
||||
import ProjectService from '@/services/project'
|
||||
import ProjectModel from '@/models/project'
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
useTitle(() => t('list.create.header'))
|
||||
useTitle(() => t('project.create.header'))
|
||||
|
||||
const showError = ref(false)
|
||||
const list = reactive(new ListModel())
|
||||
const listService = shallowReactive(new ListService())
|
||||
const listStore = useListStore()
|
||||
const project = reactive(new ProjectModel())
|
||||
const projectService = shallowReactive(new ProjectService())
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
async function createNewList() {
|
||||
if (list.title === '') {
|
||||
async function createNewProject() {
|
||||
if (project.title === '') {
|
||||
showError.value = true
|
||||
return
|
||||
}
|
||||
showError.value = false
|
||||
|
||||
list.namespaceId = Number(route.params.namespaceId as string)
|
||||
const newList = await listStore.createList(list)
|
||||
project.namespaceId = Number(route.params.namespaceId as string)
|
||||
const newProject = await projectStore.createProject(project)
|
||||
await router.push({
|
||||
name: 'list.index',
|
||||
params: { listId: newList.id },
|
||||
name: 'project.index',
|
||||
params: { projectId: newProject.id },
|
||||
})
|
||||
success({message: t('list.create.createdSuccess') })
|
||||
success({message: t('project.create.createdSuccess') })
|
||||
}
|
||||
</script>
|
197
src/views/project/ProjectGantt.vue
Normal file
197
src/views/project/ProjectGantt.vue
Normal file
@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<ProjectWrapper class="project-gantt" :project-id="filters.projectId" viewName="gantt">
|
||||
<template #header>
|
||||
<card :has-content="false">
|
||||
<div class="gantt-options">
|
||||
<div class="field">
|
||||
<label class="label" for="range">{{ $t('project.gantt.range') }}</label>
|
||||
<div class="control">
|
||||
<Foo
|
||||
ref="flatPickerEl"
|
||||
:config="flatPickerConfig"
|
||||
class="input"
|
||||
id="range"
|
||||
:placeholder="$t('project.gantt.range')"
|
||||
v-model="flatPickerDateRange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" v-if="!hasDefaultFilters">
|
||||
<label class="label" for="range">Reset</label>
|
||||
<div class="control">
|
||||
<x-button @click="setDefaultFilters">Reset</x-button>
|
||||
</div>
|
||||
</div>
|
||||
<fancycheckbox class="is-block" v-model="filters.showTasksWithoutDates">
|
||||
{{ $t('project.gantt.showTasksWithoutDates') }}
|
||||
</fancycheckbox>
|
||||
</div>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div class="gantt-chart-container">
|
||||
<card :has-content="false" :padding="false" class="has-overflow">
|
||||
<gantt-chart
|
||||
:filters="filters"
|
||||
:tasks="tasks"
|
||||
:isLoading="isLoading"
|
||||
:default-task-start-date="defaultTaskStartDate"
|
||||
:default-task-end-date="defaultTaskEndDate"
|
||||
@update:task="updateTask"
|
||||
/>
|
||||
<TaskForm v-if="canWrite" @create-task="addGanttTask" />
|
||||
</card>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, toRefs} from 'vue'
|
||||
import type Flatpickr from 'flatpickr'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import type {RouteLocationNormalized} from 'vue-router'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
|
||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import TaskForm from '@/components/tasks/TaskForm.vue'
|
||||
|
||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
import {useGanttFilters} from './helpers/useGanttFilters'
|
||||
import {RIGHTS} from '@/constants/rights'
|
||||
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
type Options = Flatpickr.Options.Options
|
||||
|
||||
const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttChart.vue'))
|
||||
|
||||
const props = defineProps<{route: RouteLocationNormalized}>()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const canWrite = computed(() => baseStore.currentProject.maxRight > RIGHTS.READ)
|
||||
|
||||
const {route} = toRefs(props)
|
||||
const {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
setDefaultFilters,
|
||||
tasks,
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
} = useGanttFilters(route)
|
||||
|
||||
const DEFAULT_DATE_RANGE_DAYS = 7
|
||||
|
||||
const today = new Date()
|
||||
const defaultTaskStartDate: DateISO = new Date(today.setHours(0, 0, 0, 0)).toISOString()
|
||||
const defaultTaskEndDate: DateISO = new Date(new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate() + DEFAULT_DATE_RANGE_DAYS,
|
||||
).setHours(23, 59, 0, 0)).toISOString()
|
||||
|
||||
async function addGanttTask(title: ITask['title']) {
|
||||
return await addTask({
|
||||
title,
|
||||
projectId: filters.value.projectId,
|
||||
startDate: defaultTaskStartDate,
|
||||
endDate: defaultTaskEndDate,
|
||||
})
|
||||
}
|
||||
|
||||
const flatPickerEl = ref<typeof Foo | null>(null)
|
||||
const flatPickerDateRange = computed<Date[]>({
|
||||
get: () => ([
|
||||
new Date(filters.value.dateFrom),
|
||||
new Date(filters.value.dateTo),
|
||||
]),
|
||||
set(newVal) {
|
||||
const [dateFrom, dateTo] = newVal.map((date) => date?.toISOString())
|
||||
|
||||
// only set after whole range has been selected
|
||||
if (!dateTo) return
|
||||
|
||||
Object.assign(filters.value, {dateFrom, dateTo})
|
||||
},
|
||||
})
|
||||
|
||||
const initialDateRange = [filters.value.dateFrom, filters.value.dateTo]
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const authStore = useAuthStore()
|
||||
const flatPickerConfig = computed<Options>(() => ({
|
||||
altFormat: t('date.altFormatShort'),
|
||||
altInput: true,
|
||||
defaultDate: initialDateRange,
|
||||
enableTime: false,
|
||||
mode: 'range',
|
||||
locale: {
|
||||
firstDayOfWeek: authStore.settings.weekStart,
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gantt-chart-container {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gantt-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.link-share-view:not(.has-background)) .gantt-options {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
.card-content {
|
||||
padding: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0;
|
||||
width: 33%;
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-top: .5rem;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
&, .input {
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.select,
|
||||
.select select {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: .9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
42
src/views/project/ProjectInfo.vue
Normal file
42
src/views/project/ProjectInfo.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<modal
|
||||
@close="$router.back()"
|
||||
>
|
||||
<card
|
||||
:title="project.title"
|
||||
>
|
||||
<div class="has-text-left" v-html="htmlDescription" v-if="htmlDescription !== ''"></div>
|
||||
<p v-else class="is-italic">
|
||||
{{ $t('project.noDescriptionAvailable') }}
|
||||
</p>
|
||||
</card>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import {setupMarkdownRenderer} from '@/helpers/markdownRenderer'
|
||||
import {marked} from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const project = computed(() => projectStore.getProjectById(props.projectId))
|
||||
const htmlDescription = computed(() => {
|
||||
const description = project.value?.description || ''
|
||||
if (description === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
setupMarkdownRenderer(createRandomID())
|
||||
return DOMPurify.sanitize(marked(description), {ADD_ATTR: ['target']})
|
||||
})
|
||||
</script>
|
795
src/views/project/ProjectKanban.vue
Normal file
795
src/views/project/ProjectKanban.vue
Normal file
@ -0,0 +1,795 @@
|
||||
<template>
|
||||
<ProjectWrapper
|
||||
class="project-kanban"
|
||||
:project-id="projectId"
|
||||
viewName="kanban"
|
||||
>
|
||||
<template #header>
|
||||
<div class="filter-container" v-if="!isSavedFilter(project)">
|
||||
<div class="items">
|
||||
<filter-popup v-model="params" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div class="kanban-view">
|
||||
<div
|
||||
:class="{ 'is-loading': loading && !oneTaskUpdating}"
|
||||
class="kanban kanban-bucket-container loader-container"
|
||||
>
|
||||
<draggable
|
||||
v-bind="DRAG_OPTIONS"
|
||||
:modelValue="buckets"
|
||||
@update:modelValue="updateBuckets"
|
||||
@end="updateBucketPosition"
|
||||
@start="() => dragBucket = true"
|
||||
group="buckets"
|
||||
:disabled="!canWrite || newTaskInputFocused"
|
||||
tag="ul"
|
||||
:item-key="({id}: IBucket) => `bucket${id}`"
|
||||
:component-data="bucketDraggableComponentData"
|
||||
>
|
||||
<template #item="{element: bucket, index: bucketIndex }">
|
||||
<div
|
||||
class="bucket"
|
||||
:class="{'is-collapsed': collapsedBuckets[bucket.id]}"
|
||||
>
|
||||
<div class="bucket-header" @click="() => unCollapseBucket(bucket)">
|
||||
<span
|
||||
v-if="bucket.isDoneBucket"
|
||||
class="icon is-small has-text-success mr-2"
|
||||
v-tooltip="$t('project.kanban.doneBucketHint')"
|
||||
>
|
||||
<icon icon="check-double"/>
|
||||
</span>
|
||||
<h2
|
||||
@keydown.enter.prevent.stop="($event.target as HTMLElement).blur()"
|
||||
@keydown.esc.prevent.stop="($event.target as HTMLElement).blur()"
|
||||
@blur="saveBucketTitle(bucket.id, ($event.target as HTMLElement).textContent as string)"
|
||||
@click="focusBucketTitle"
|
||||
class="title input"
|
||||
:contenteditable="(bucketTitleEditable && canWrite && !collapsedBuckets[bucket.id]) ? true : undefined"
|
||||
:spellcheck="false">{{ bucket.title }}</h2>
|
||||
<span
|
||||
:class="{'is-max': bucket.tasks.length >= bucket.limit}"
|
||||
class="limit"
|
||||
v-if="bucket.limit > 0">
|
||||
{{ bucket.tasks.length }}/{{ bucket.limit }}
|
||||
</span>
|
||||
<dropdown
|
||||
class="is-right options"
|
||||
v-if="canWrite && !collapsedBuckets[bucket.id]"
|
||||
trigger-icon="ellipsis-v"
|
||||
@close="() => showSetLimitInput = false"
|
||||
>
|
||||
<dropdown-item
|
||||
@click.stop="showSetLimitInput = true"
|
||||
>
|
||||
<div class="field has-addons" v-if="showSetLimitInput">
|
||||
<div class="control">
|
||||
<input
|
||||
@keyup.esc="() => showSetLimitInput = false"
|
||||
@keyup.enter="() => showSetLimitInput = false"
|
||||
:value="bucket.limit"
|
||||
@input="(event) => setBucketLimit(bucket.id, parseInt((event.target as HTMLInputElement).value))"
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
v-focus.always
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
:disabled="bucket.limit < 0"
|
||||
:icon="['far', 'save']"
|
||||
:shadow="false"
|
||||
v-cy="'setBucketLimit'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
{{
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
|
||||
}}
|
||||
</template>
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
@click.stop="toggleDoneBucket(bucket)"
|
||||
v-tooltip="$t('project.kanban.doneBucketHintExtended')"
|
||||
>
|
||||
<span class="icon is-small" :class="{'has-text-success': bucket.isDoneBucket}">
|
||||
<icon icon="check-double"/>
|
||||
</span>
|
||||
{{ $t('project.kanban.doneBucket') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
@click.stop="() => collapseBucket(bucket)"
|
||||
>
|
||||
{{ $t('project.kanban.collapse') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:class="{'is-disabled': buckets.length <= 1}"
|
||||
@click.stop="() => deleteBucketModal(bucket.id)"
|
||||
class="has-text-danger"
|
||||
v-tooltip="buckets.length <= 1 ? $t('project.kanban.deleteLast') : ''"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<icon icon="trash-alt"/>
|
||||
</span>
|
||||
{{ $t('misc.delete') }}
|
||||
</dropdown-item>
|
||||
</dropdown>
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-bind="DRAG_OPTIONS"
|
||||
:modelValue="bucket.tasks"
|
||||
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
|
||||
@start="() => dragstart(bucket)"
|
||||
@end="updateTaskPosition"
|
||||
:group="{name: 'tasks', put: shouldAcceptDrop(bucket) && !dragBucket}"
|
||||
:disabled="!canWrite"
|
||||
:data-bucket-index="bucketIndex"
|
||||
tag="ul"
|
||||
:item-key="(task: ITask) => `bucket${bucket.id}-task${task.id}`"
|
||||
:component-data="getTaskDraggableTaskComponentData(bucket)"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="bucket-footer" v-if="canWrite">
|
||||
<div class="field" v-if="showNewTaskInput[bucket.id]">
|
||||
<div class="control" :class="{'is-loading': loading || taskLoading}">
|
||||
<input
|
||||
class="input"
|
||||
:disabled="loading || taskLoading || undefined"
|
||||
@focusout="toggleShowNewTaskInput(bucket.id)"
|
||||
@focusin="() => newTaskInputFocused = true"
|
||||
@keyup.enter="addTaskToBucket(bucket.id)"
|
||||
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
|
||||
:placeholder="$t('project.kanban.addTaskPlaceholder')"
|
||||
type="text"
|
||||
v-focus.always
|
||||
v-model="newTaskText"
|
||||
/>
|
||||
</div>
|
||||
<p class="help is-danger" v-if="newTaskError[bucket.id] && newTaskText === ''">
|
||||
{{ $t('project.create.addTitleRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
<x-button
|
||||
@click="toggleShowNewTaskInput(bucket.id)"
|
||||
class="is-fullwidth has-text-centered"
|
||||
:shadow="false"
|
||||
v-else
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ bucket.tasks.length === 0 ? $t('project.kanban.addTask') : $t('project.kanban.addAnotherTask') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item="{element: task}">
|
||||
<div class="task-item">
|
||||
<kanban-card class="kanban-card" :task="task" :loading="taskUpdating[task.id] ?? false"/>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div class="bucket new-bucket" v-if="canWrite && !loading && buckets.length > 0">
|
||||
<input
|
||||
:class="{'is-loading': loading}"
|
||||
:disabled="loading || undefined"
|
||||
@blur="() => showNewBucketInput = false"
|
||||
@keyup.enter="createNewBucket"
|
||||
@keyup.esc="($event.target as HTMLInputElement).blur()"
|
||||
class="input"
|
||||
:placeholder="$t('project.kanban.addBucketPlaceholder')"
|
||||
type="text"
|
||||
v-focus.always
|
||||
v-if="showNewBucketInput"
|
||||
v-model="newBucketTitle"
|
||||
/>
|
||||
<x-button
|
||||
v-else
|
||||
@click="() => showNewBucketInput = true"
|
||||
:shadow="false"
|
||||
class="is-transparent is-fullwidth has-text-centered"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
>
|
||||
{{ $t('project.kanban.addBucket') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modal
|
||||
:enabled="showBucketDeleteModal"
|
||||
@close="showBucketDeleteModal = false"
|
||||
@submit="deleteBucket()"
|
||||
>
|
||||
<template #header><span>{{ $t('project.kanban.deleteHeaderBucket') }}</span></template>
|
||||
|
||||
<template #text>
|
||||
<p>{{ $t('project.kanban.deleteBucketText1') }}<br/>
|
||||
{{ $t('project.kanban.deleteBucketText2') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref, watch, type PropType} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import {klona} from 'klona/lite'
|
||||
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
import BucketModel from '@/models/bucket'
|
||||
|
||||
import type {IBucket} from '@/modelTypes/IBucket'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
import {useKanbanStore} from '@/stores/kanban'
|
||||
|
||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import FilterPopup from '@/components/project/partials/filter-popup.vue'
|
||||
import KanbanCard from '@/components/tasks/partials/kanban-card.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
|
||||
import {getCollapsedBucketState, saveCollapsedBucketState, type CollapsedBuckets} from '@/helpers/saveCollapsedBucketState'
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {success} from '@/message'
|
||||
|
||||
const DRAG_OPTIONS = {
|
||||
// sortable options
|
||||
animation: 150,
|
||||
ghostClass: 'ghost',
|
||||
dragClass: 'task-dragging',
|
||||
delayOnTouchOnly: true,
|
||||
delay: 150,
|
||||
} as const
|
||||
|
||||
const MIN_SCROLL_HEIGHT_PERCENT = 0.25
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number as PropType<IProject['id']>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
const taskContainerRefs = ref<{[id: IBucket['id']]: HTMLElement}>({})
|
||||
|
||||
const drag = ref(false)
|
||||
const dragBucket = ref(false)
|
||||
const sourceBucket = ref(0)
|
||||
|
||||
const showBucketDeleteModal = ref(false)
|
||||
const bucketToDelete = ref(0)
|
||||
const bucketTitleEditable = ref(false)
|
||||
|
||||
const newTaskText = ref('')
|
||||
const showNewTaskInput = ref<{[id: IBucket['id']]: boolean}>({})
|
||||
|
||||
const newBucketTitle = ref('')
|
||||
const showNewBucketInput = ref(false)
|
||||
const newTaskError = ref<{[id: IBucket['id']]: boolean}>({})
|
||||
const newTaskInputFocused = ref(false)
|
||||
|
||||
const showSetLimitInput = ref(false)
|
||||
const collapsedBuckets = ref<CollapsedBuckets>({})
|
||||
|
||||
// We're using this to show the loading animation only at the task when updating it
|
||||
const taskUpdating = ref<{[id: ITask['id']]: boolean}>({})
|
||||
const oneTaskUpdating = ref(false)
|
||||
|
||||
const params = ref({
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
filter_concat: 'and',
|
||||
})
|
||||
|
||||
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
|
||||
return {
|
||||
ref: (el: HTMLElement) => setTaskContainerRef(bucket.id, el),
|
||||
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, bucket.projectId, event.target as HTMLElement),
|
||||
type: 'transition-group',
|
||||
name: !drag.value ? 'move-card' : null,
|
||||
class: [
|
||||
'tasks',
|
||||
{'dragging-disabled': !canWrite.value},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const bucketDraggableComponentData = computed(() => ({
|
||||
type: 'transition-group',
|
||||
name: !dragBucket.value ? 'move-bucket' : null,
|
||||
class: [
|
||||
'kanban-bucket-container',
|
||||
{'dragging-disabled': !canWrite.value},
|
||||
],
|
||||
}))
|
||||
|
||||
const canWrite = computed(() => baseStore.currentProject.maxRight > Rights.READ)
|
||||
const project = computed(() => baseStore.currentProject)
|
||||
|
||||
const buckets = computed(() => kanbanStore.buckets)
|
||||
const loading = computed(() => kanbanStore.isLoading)
|
||||
|
||||
const taskLoading = computed(() => taskStore.isLoading)
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
projectId: props.projectId,
|
||||
params: params.value,
|
||||
}),
|
||||
({projectId, params}) => {
|
||||
if (projectId === undefined) {
|
||||
return
|
||||
}
|
||||
collapsedBuckets.value = getCollapsedBucketState(projectId)
|
||||
kanbanStore.loadBucketsForProject({projectId, params})
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
)
|
||||
|
||||
function setTaskContainerRef(id: IBucket['id'], el: HTMLElement) {
|
||||
if (!el) return
|
||||
taskContainerRefs.value[id] = el
|
||||
}
|
||||
|
||||
function handleTaskContainerScroll(id: IBucket['id'], projectId: IProject['id'], el: HTMLElement) {
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
const scrollTopMax = el.scrollHeight - el.clientHeight
|
||||
const threshold = el.scrollTop + el.scrollTop * MIN_SCROLL_HEIGHT_PERCENT
|
||||
if (scrollTopMax > threshold) {
|
||||
return
|
||||
}
|
||||
|
||||
kanbanStore.loadNextTasksForBucket({
|
||||
projectId: projectId,
|
||||
params: params.value,
|
||||
bucketId: id,
|
||||
})
|
||||
}
|
||||
|
||||
function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
|
||||
const bucket = kanbanStore.getBucketById(bucketId)
|
||||
|
||||
if (bucket === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
kanbanStore.setBucketById({
|
||||
...bucket,
|
||||
tasks,
|
||||
})
|
||||
}
|
||||
|
||||
async function updateTaskPosition(e) {
|
||||
drag.value = false
|
||||
|
||||
// While we could just pass the bucket index in through the function call, this would not give us the
|
||||
// new bucket id when a task has been moved between buckets, only the new bucket. Using the data-bucket-id
|
||||
// of the drop target works all the time.
|
||||
const bucketIndex = parseInt(e.to.dataset.bucketIndex)
|
||||
|
||||
const newBucket = buckets.value[bucketIndex]
|
||||
|
||||
// HACK:
|
||||
// this is a hacky workaround for a known problem of vue.draggable.next when using the footer slot
|
||||
// the problem: https://github.com/SortableJS/vue.draggable.next/issues/108
|
||||
// This hack doesn't remove the problem that the ghost item is still displayed below the footer
|
||||
// It just makes releasing the item possible.
|
||||
|
||||
// The newIndex of the event doesn't count in the elements of the footer slot.
|
||||
// This is why in case the length of the tasks is identical with the newIndex
|
||||
// we have to remove 1 to get the correct index.
|
||||
const newTaskIndex = newBucket.tasks.length === e.newIndex
|
||||
? e.newIndex - 1
|
||||
: e.newIndex
|
||||
|
||||
const task = newBucket.tasks[newTaskIndex]
|
||||
const taskBefore = newBucket.tasks[newTaskIndex - 1] ?? null
|
||||
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
|
||||
taskUpdating.value[task.id] = true
|
||||
|
||||
const newTask = klona(task) // cloning the task to avoid pinia store manipulation
|
||||
newTask.bucketId = newBucket.id
|
||||
newTask.kanbanPosition = calculateItemPosition(
|
||||
taskBefore !== null ? taskBefore.kanbanPosition : null,
|
||||
taskAfter !== null ? taskAfter.kanbanPosition : null,
|
||||
)
|
||||
|
||||
try {
|
||||
await taskStore.update(newTask)
|
||||
|
||||
// Make sure the first and second task don't both get position 0 assigned
|
||||
if(newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
|
||||
const taskAfterAfter = newBucket.tasks[newTaskIndex + 2] ?? null
|
||||
const newTaskAfter = klona(taskAfter) // cloning the task to avoid pinia store manipulation
|
||||
newTaskAfter.bucketId = newBucket.id
|
||||
newTaskAfter.kanbanPosition = calculateItemPosition(
|
||||
0,
|
||||
taskAfterAfter !== null ? taskAfterAfter.kanbanPosition : null,
|
||||
)
|
||||
|
||||
await taskStore.update(newTaskAfter)
|
||||
}
|
||||
} finally {
|
||||
taskUpdating.value[task.id] = false
|
||||
oneTaskUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowNewTaskInput(bucketId: IBucket['id']) {
|
||||
showNewTaskInput.value[bucketId] = !showNewTaskInput.value[bucketId]
|
||||
newTaskInputFocused.value = false
|
||||
}
|
||||
|
||||
async function addTaskToBucket(bucketId: IBucket['id']) {
|
||||
if (newTaskText.value === '') {
|
||||
newTaskError.value[bucketId] = true
|
||||
return
|
||||
}
|
||||
newTaskError.value[bucketId] = false
|
||||
|
||||
const task = await taskStore.createNewTask({
|
||||
title: newTaskText.value,
|
||||
bucketId,
|
||||
projectId: props.projectId,
|
||||
})
|
||||
newTaskText.value = ''
|
||||
kanbanStore.addTaskToBucket(task)
|
||||
scrollTaskContainerToBottom(bucketId)
|
||||
}
|
||||
|
||||
function scrollTaskContainerToBottom(bucketId: IBucket['id']) {
|
||||
const bucketEl = taskContainerRefs.value[bucketId]
|
||||
if (!bucketEl) {
|
||||
return
|
||||
}
|
||||
bucketEl.scrollTop = bucketEl.scrollHeight
|
||||
}
|
||||
|
||||
async function createNewBucket() {
|
||||
if (newBucketTitle.value === '') {
|
||||
return
|
||||
}
|
||||
|
||||
await kanbanStore.createBucket(new BucketModel({
|
||||
title: newBucketTitle.value,
|
||||
projectId: props.projectId,
|
||||
}))
|
||||
newBucketTitle.value = ''
|
||||
showNewBucketInput.value = false
|
||||
}
|
||||
|
||||
function deleteBucketModal(bucketId: IBucket['id']) {
|
||||
if (buckets.value.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
bucketToDelete.value = bucketId
|
||||
showBucketDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteBucket() {
|
||||
try {
|
||||
await kanbanStore.deleteBucket({
|
||||
bucket: new BucketModel({
|
||||
id: bucketToDelete.value,
|
||||
projectId: props.projectId,
|
||||
}),
|
||||
params: params.value,
|
||||
})
|
||||
success({message: t('project.kanban.deleteBucketSuccess')})
|
||||
} finally {
|
||||
showBucketDeleteModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** This little helper allows us to drag a bucket around at the title without focusing on it right away. */
|
||||
async function focusBucketTitle(e: Event) {
|
||||
bucketTitleEditable.value = true
|
||||
await nextTick()
|
||||
const target = e.target as HTMLInputElement
|
||||
target.focus()
|
||||
}
|
||||
|
||||
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
|
||||
await kanbanStore.updateBucketTitle({
|
||||
id: bucketId,
|
||||
title: bucketTitle,
|
||||
})
|
||||
bucketTitleEditable.value = false
|
||||
}
|
||||
|
||||
function updateBuckets(value: IBucket[]) {
|
||||
// (1) buckets get updated in store and tasks positions get invalidated
|
||||
kanbanStore.setBuckets(value)
|
||||
}
|
||||
|
||||
// TODO: fix type
|
||||
function updateBucketPosition(e: {newIndex: number}) {
|
||||
// (2) bucket positon is changed
|
||||
dragBucket.value = false
|
||||
|
||||
const bucket = buckets.value[e.newIndex]
|
||||
const bucketBefore = buckets.value[e.newIndex - 1] ?? null
|
||||
const bucketAfter = buckets.value[e.newIndex + 1] ?? null
|
||||
|
||||
kanbanStore.updateBucket({
|
||||
id: bucket.id,
|
||||
position: calculateItemPosition(
|
||||
bucketBefore !== null ? bucketBefore.position : null,
|
||||
bucketAfter !== null ? bucketAfter.position : null,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async function setBucketLimit(bucketId: IBucket['id'], limit: number) {
|
||||
if (limit < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await kanbanStore.updateBucket({
|
||||
...kanbanStore.getBucketById(bucketId),
|
||||
limit,
|
||||
})
|
||||
success({message: t('project.kanban.bucketLimitSavedSuccess')})
|
||||
}
|
||||
|
||||
function shouldAcceptDrop(bucket: IBucket) {
|
||||
return (
|
||||
// When dragging from a bucket who has its limit reached, dragging should still be possible
|
||||
bucket.id === sourceBucket.value ||
|
||||
// If there is no limit set, dragging & dropping should always work
|
||||
bucket.limit === 0 ||
|
||||
// Disallow dropping to buckets which have their limit reached
|
||||
bucket.tasks.length < bucket.limit
|
||||
)
|
||||
}
|
||||
|
||||
function dragstart(bucket: IBucket) {
|
||||
drag.value = true
|
||||
sourceBucket.value = bucket.id
|
||||
}
|
||||
|
||||
async function toggleDoneBucket(bucket: IBucket) {
|
||||
await kanbanStore.updateBucket({
|
||||
...bucket,
|
||||
isDoneBucket: !bucket.isDoneBucket,
|
||||
})
|
||||
success({message: t('project.kanban.doneBucketSavedSuccess')})
|
||||
}
|
||||
|
||||
function collapseBucket(bucket: IBucket) {
|
||||
collapsedBuckets.value[bucket.id] = true
|
||||
saveCollapsedBucketState(props.projectId, collapsedBuckets.value)
|
||||
}
|
||||
|
||||
function unCollapseBucket(bucket: IBucket) {
|
||||
if (!collapsedBuckets.value[bucket.id]) {
|
||||
return
|
||||
}
|
||||
|
||||
collapsedBuckets.value[bucket.id] = false
|
||||
saveCollapsedBucketState(props.projectId, collapsedBuckets.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$ease-out: all .3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
$bucket-width: 300px;
|
||||
$bucket-header-height: 60px;
|
||||
$bucket-right-margin: 1rem;
|
||||
|
||||
$crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1rem - 1.5rem - 11px';
|
||||
$crazy-height-calculation-tasks: '#{$crazy-height-calculation} - 1rem - 2.5rem - 2rem - #{$button-height} - 1rem';
|
||||
$filter-container-height: '1rem - #{$switch-view-height}';
|
||||
|
||||
// FIXME:
|
||||
.app-content.project\.kanban, .app-content.task\.detail {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.kanban {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
height: calc(#{$crazy-height-calculation});
|
||||
margin: 0 -1.5rem;
|
||||
padding: 0 1.5rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
&-bucket-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
position: relative;
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0.25rem;
|
||||
right: 0.5rem;
|
||||
bottom: 0.25rem;
|
||||
left: 0.5rem;
|
||||
border: 3px dashed var(--grey-300);
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.bucket {
|
||||
border-radius: $radius;
|
||||
position: relative;
|
||||
|
||||
margin: 0 $bucket-right-margin 0 0;
|
||||
max-height: calc(100% - 1rem); // 1rem spacing to the bottom
|
||||
min-height: 20px;
|
||||
width: $bucket-width;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; // Make sure the edges are always rounded
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
.tasks {
|
||||
overflow: hidden auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background-color: var(--grey-100);
|
||||
padding: .25rem .5rem;
|
||||
|
||||
&:first-of-type {
|
||||
padding-top: .5rem;
|
||||
}
|
||||
&:last-of-type {
|
||||
padding-bottom: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
&.new-bucket {
|
||||
// Because of reasons, this button ignores the margin we gave it to the right.
|
||||
// To make it still look like it has some, we modify the container to have a padding of 1rem,
|
||||
// which is the same as the margin it should have. Then we make the container itself bigger
|
||||
// to hide the fact we just made the button smaller.
|
||||
min-width: calc(#{$bucket-width} + 1rem);
|
||||
background: transparent;
|
||||
padding-right: 1rem;
|
||||
|
||||
.button {
|
||||
background: var(--grey-100);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-collapsed {
|
||||
align-self: flex-start;
|
||||
transform: rotate(90deg) translateY(-100%);
|
||||
transform-origin: top left;
|
||||
// Using negative margins instead of translateY here to make all other buckets fill the empty space
|
||||
margin-right: calc((#{$bucket-width} - #{$bucket-header-height} - #{$bucket-right-margin}) * -1);
|
||||
cursor: pointer;
|
||||
|
||||
.tasks, .bucket-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bucket-header {
|
||||
background-color: var(--grey-100);
|
||||
height: min-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .5rem;
|
||||
height: $bucket-header-height;
|
||||
|
||||
.limit {
|
||||
padding: 0 .5rem;
|
||||
font-weight: bold;
|
||||
|
||||
&.is-max {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.title.input {
|
||||
height: auto;
|
||||
padding: .4rem .5rem;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.bucket-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
height: min-content;
|
||||
padding: .5rem;
|
||||
background-color: var(--grey-100);
|
||||
border-bottom-left-radius: $radius;
|
||||
border-bottom-right-radius: $radius;
|
||||
transform: none;
|
||||
|
||||
.button {
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: This does not seem to work
|
||||
.task-dragging {
|
||||
transform: rotateZ(3deg);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.move-card-move {
|
||||
transform: rotateZ(3deg);
|
||||
transition: transform $transition-duration;
|
||||
}
|
||||
|
||||
.move-card-leave-from,
|
||||
.move-card-leave-to,
|
||||
.move-card-leave-active {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
324
src/views/project/ProjectList.vue
Normal file
324
src/views/project/ProjectList.vue
Normal file
@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<ProjectWrapper class="project-list" :project-id="projectId" viewName="project">
|
||||
<template #header>
|
||||
<div
|
||||
class="filter-container"
|
||||
v-if="!isSavedFilter(project)"
|
||||
>
|
||||
<div class="items">
|
||||
<div class="search">
|
||||
<div :class="{ hidden: !showTaskSearch }" class="field has-addons">
|
||||
<div class="control has-icons-left has-icons-right">
|
||||
<input
|
||||
@blur="hideSearchBar()"
|
||||
@keyup.enter="searchTasks"
|
||||
class="input"
|
||||
:placeholder="$t('misc.search')"
|
||||
type="text"
|
||||
v-focus
|
||||
v-model="searchTerm"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<icon icon="search"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
:loading="loading"
|
||||
@click="searchTasks"
|
||||
:shadow="false"
|
||||
>
|
||||
{{ $t('misc.search') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
<x-button
|
||||
@click="showTaskSearch = !showTaskSearch"
|
||||
icon="search"
|
||||
variant="secondary"
|
||||
v-if="!showTaskSearch"
|
||||
/>
|
||||
</div>
|
||||
<filter-popup
|
||||
v-model="params"
|
||||
@update:modelValue="prepareFiltersAndLoadTasks()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div
|
||||
:class="{ 'is-loading': loading }"
|
||||
class="loader-container is-max-width-desktop list-view"
|
||||
>
|
||||
<card :padding="false" :has-content="false" class="has-overflow">
|
||||
<add-task
|
||||
v-if="!project.isArchived && canWrite"
|
||||
class="list-view__add-task"
|
||||
ref="addTaskRef"
|
||||
:default-position="firstNewPosition"
|
||||
@taskAdded="updateTaskList"
|
||||
/>
|
||||
|
||||
<nothing v-if="ctaVisible && tasks.length === 0 && !loading">
|
||||
{{ $t('project.list.empty') }}
|
||||
<ButtonLink @click="focusNewTaskInput()">
|
||||
{{ $t('project.list.newTaskCta') }}
|
||||
</ButtonLink>
|
||||
</nothing>
|
||||
|
||||
|
||||
<draggable
|
||||
v-if="tasks && tasks.length > 0"
|
||||
v-bind="DRAG_OPTIONS"
|
||||
v-model="tasks"
|
||||
group="tasks"
|
||||
@start="() => drag = true"
|
||||
@end="saveTaskPosition"
|
||||
handle=".handle"
|
||||
:disabled="!canWrite"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
:component-data="{
|
||||
class: {
|
||||
tasks: true,
|
||||
'dragging-disabled': !canWrite || isAlphabeticalSorting
|
||||
},
|
||||
type: 'transition-group'
|
||||
}"
|
||||
>
|
||||
<template #item="{element: t}">
|
||||
<single-task-in-project
|
||||
:show-list-color="false"
|
||||
:disabled="!canWrite"
|
||||
:can-mark-as-done="canWrite || isSavedFilter(project)"
|
||||
:the-task="t"
|
||||
@taskUpdated="updateTasks"
|
||||
>
|
||||
<template v-if="canWrite">
|
||||
<span class="icon handle">
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
</template>
|
||||
</single-task-in-project>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<Pagination
|
||||
:total-pages="totalPages"
|
||||
:current-page="currentPage"
|
||||
/>
|
||||
</card>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'List' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, toRef, nextTick, onMounted, type PropType} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
|
||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
||||
import AddTask from '@/components/tasks/add-task.vue'
|
||||
import SingleTaskInProject from '@/components/tasks/partials/singleTaskInProject.vue'
|
||||
import FilterPopup from '@/components/project/partials/filter-popup.vue'
|
||||
import Nothing from '@/components/misc/nothing.vue'
|
||||
import Pagination from '@/components/misc/pagination.vue'
|
||||
import {ALPHABETICAL_SORT} from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {useTaskList} from '@/composables/useTaskList'
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
function sortTasks(tasks: ITask[]) {
|
||||
if (tasks === null || Array.isArray(tasks) && tasks.length === 0) {
|
||||
return
|
||||
}
|
||||
return tasks.sort((a, b) => {
|
||||
if (a.done < b.done)
|
||||
return -1
|
||||
if (a.done > b.done)
|
||||
return 1
|
||||
|
||||
if (a.position < b.position)
|
||||
return -1
|
||||
if (a.position > b.position)
|
||||
return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number as PropType<IProject['id']>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const ctaVisible = ref(false)
|
||||
const showTaskSearch = ref(false)
|
||||
|
||||
const drag = ref(false)
|
||||
const DRAG_OPTIONS = {
|
||||
animation: 100,
|
||||
ghostClass: 'task-ghost',
|
||||
} as const
|
||||
|
||||
const {
|
||||
tasks,
|
||||
loading,
|
||||
totalPages,
|
||||
currentPage,
|
||||
loadTasks,
|
||||
searchTerm,
|
||||
params,
|
||||
sortByParam,
|
||||
} = useTaskList(toRef(props, 'projectId'), {position: 'asc' })
|
||||
|
||||
|
||||
const isAlphabeticalSorting = computed(() => {
|
||||
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
|
||||
})
|
||||
|
||||
const firstNewPosition = computed(() => {
|
||||
if (tasks.value.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return calculateItemPosition(null, tasks.value[0].position)
|
||||
})
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const baseStore = useBaseStore()
|
||||
const project = computed(() => baseStore.currentProject)
|
||||
|
||||
const canWrite = computed(() => {
|
||||
return project.value.maxRight > Rights.READ && project.value.id > 0
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
ctaVisible.value = true
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function searchTasks() {
|
||||
// Only search if the search term changed
|
||||
if (route.query as unknown as string === searchTerm.value) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
name: 'project.list',
|
||||
query: {search: searchTerm.value},
|
||||
})
|
||||
}
|
||||
|
||||
function hideSearchBar() {
|
||||
// This is a workaround.
|
||||
// When clicking on the search button, @blur from the input is fired. If we
|
||||
// would then directly hide the whole search bar directly, no click event
|
||||
// from the button gets fired. To prevent this, we wait 200ms until we hide
|
||||
// everything so the button has a chance of firing the search event.
|
||||
setTimeout(() => {
|
||||
showTaskSearch.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const addTaskRef = ref<typeof AddTask | null>(null)
|
||||
function focusNewTaskInput() {
|
||||
addTaskRef.value?.focusTaskInput()
|
||||
}
|
||||
|
||||
function updateTaskList(task: ITask) {
|
||||
if (isAlphabeticalSorting.value ) {
|
||||
// reload tasks with current filter and sorting
|
||||
loadTasks()
|
||||
}
|
||||
else {
|
||||
tasks.value = [
|
||||
task,
|
||||
...tasks.value,
|
||||
]
|
||||
}
|
||||
|
||||
baseStore.setHasTasks(true)
|
||||
}
|
||||
|
||||
function updateTasks(updatedTask: ITask) {
|
||||
for (const t in tasks.value) {
|
||||
if (tasks.value[t].id === updatedTask.id) {
|
||||
tasks.value[t] = updatedTask
|
||||
break
|
||||
}
|
||||
}
|
||||
// FIXME: Use computed
|
||||
sortTasks(tasks.value)
|
||||
}
|
||||
|
||||
async function saveTaskPosition(e) {
|
||||
drag.value = false
|
||||
|
||||
const task = tasks.value[e.newIndex]
|
||||
const taskBefore = tasks.value[e.newIndex - 1] ?? null
|
||||
const taskAfter = tasks.value[e.newIndex + 1] ?? null
|
||||
|
||||
const newTask = {
|
||||
...task,
|
||||
position: calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null),
|
||||
}
|
||||
|
||||
const updatedTask = await taskStore.update(newTask)
|
||||
tasks.value[e.newIndex] = updatedTask
|
||||
}
|
||||
|
||||
function prepareFiltersAndLoadTasks() {
|
||||
if(isAlphabeticalSorting.value) {
|
||||
sortByParam.value = {}
|
||||
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
|
||||
}
|
||||
|
||||
loadTasks()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tasks {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.task-ghost {
|
||||
border-radius: $radius;
|
||||
background: var(--grey-100);
|
||||
border: 2px dashed var(--grey-300);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.list-view__add-task {
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.link-share-view .card {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
303
src/views/project/ProjectTable.vue
Normal file
303
src/views/project/ProjectTable.vue
Normal file
@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<ProjectWrapper class="project-table" :project-id="projectId" viewName="table">
|
||||
<template #header>
|
||||
<div class="filter-container">
|
||||
<div class="items">
|
||||
<popup>
|
||||
<template #trigger="{toggle}">
|
||||
<x-button
|
||||
@click.prevent.stop="toggle()"
|
||||
icon="th"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('project.table.columns') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<template #content="{isOpen}">
|
||||
<card class="columns-filter" :class="{'is-open': isOpen}">
|
||||
<fancycheckbox v-model="activeColumns.index">#</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.done">
|
||||
{{ $t('task.attributes.done') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.title">
|
||||
{{ $t('task.attributes.title') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.priority">
|
||||
{{ $t('task.attributes.priority') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.labels">
|
||||
{{ $t('task.attributes.labels') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.assignees">
|
||||
{{ $t('task.attributes.assignees') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.dueDate">
|
||||
{{ $t('task.attributes.dueDate') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.startDate">
|
||||
{{ $t('task.attributes.startDate') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.endDate">
|
||||
{{ $t('task.attributes.endDate') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.percentDone">
|
||||
{{ $t('task.attributes.percentDone') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.created">
|
||||
{{ $t('task.attributes.created') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.updated">
|
||||
{{ $t('task.attributes.updated') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox v-model="activeColumns.createdBy">
|
||||
{{ $t('task.attributes.createdBy') }}
|
||||
</fancycheckbox>
|
||||
</card>
|
||||
</template>
|
||||
</popup>
|
||||
<filter-popup v-model="params"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div :class="{'is-loading': loading}" class="loader-container">
|
||||
<card :padding="false" :has-content="false">
|
||||
<div class="has-horizontal-overflow">
|
||||
<table class="table has-actions is-hoverable is-fullwidth mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="activeColumns.index">
|
||||
#
|
||||
<Sort :order="sortBy.index" @click="sort('index')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.done">
|
||||
{{ $t('task.attributes.done') }}
|
||||
<Sort :order="sortBy.done" @click="sort('done')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.title">
|
||||
{{ $t('task.attributes.title') }}
|
||||
<Sort :order="sortBy.title" @click="sort('title')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.priority">
|
||||
{{ $t('task.attributes.priority') }}
|
||||
<Sort :order="sortBy.priority" @click="sort('priority')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.labels">
|
||||
{{ $t('task.attributes.labels') }}
|
||||
</th>
|
||||
<th v-if="activeColumns.assignees">
|
||||
{{ $t('task.attributes.assignees') }}
|
||||
</th>
|
||||
<th v-if="activeColumns.dueDate">
|
||||
{{ $t('task.attributes.dueDate') }}
|
||||
<Sort :order="sortBy.due_date" @click="sort('due_date')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.startDate">
|
||||
{{ $t('task.attributes.startDate') }}
|
||||
<Sort :order="sortBy.start_date" @click="sort('start_date')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.endDate">
|
||||
{{ $t('task.attributes.endDate') }}
|
||||
<Sort :order="sortBy.end_date" @click="sort('end_date')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.percentDone">
|
||||
{{ $t('task.attributes.percentDone') }}
|
||||
<Sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.created">
|
||||
{{ $t('task.attributes.created') }}
|
||||
<Sort :order="sortBy.created" @click="sort('created')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.updated">
|
||||
{{ $t('task.attributes.updated') }}
|
||||
<Sort :order="sortBy.updated" @click="sort('updated')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.createdBy">
|
||||
{{ $t('task.attributes.createdBy') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :key="t.id" v-for="t in tasks">
|
||||
<td v-if="activeColumns.index">
|
||||
<router-link :to="taskDetailRoutes[t.id]">
|
||||
<template v-if="t.identifier === ''">
|
||||
#{{ t.index }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t.identifier }}
|
||||
</template>
|
||||
</router-link>
|
||||
</td>
|
||||
<td v-if="activeColumns.done">
|
||||
<Done :is-done="t.done" variant="small"/>
|
||||
</td>
|
||||
<td v-if="activeColumns.title">
|
||||
<router-link :to="taskDetailRoutes[t.id]">{{ t.title }}</router-link>
|
||||
</td>
|
||||
<td v-if="activeColumns.priority">
|
||||
<priority-label :priority="t.priority" :done="t.done" :show-all="true"/>
|
||||
</td>
|
||||
<td v-if="activeColumns.labels">
|
||||
<labels :labels="t.labels"/>
|
||||
</td>
|
||||
<td v-if="activeColumns.assignees">
|
||||
<user
|
||||
:avatar-size="27"
|
||||
:is-inline="true"
|
||||
:key="t.id + 'assignee' + a.id + i"
|
||||
:show-username="false"
|
||||
:user="a"
|
||||
v-for="(a, i) in t.assignees"
|
||||
/>
|
||||
</td>
|
||||
<date-table-cell :date="t.dueDate" v-if="activeColumns.dueDate"/>
|
||||
<date-table-cell :date="t.startDate" v-if="activeColumns.startDate"/>
|
||||
<date-table-cell :date="t.endDate" v-if="activeColumns.endDate"/>
|
||||
<td v-if="activeColumns.percentDone">{{ t.percentDone * 100 }}%</td>
|
||||
<date-table-cell :date="t.created" v-if="activeColumns.created"/>
|
||||
<date-table-cell :date="t.updated" v-if="activeColumns.updated"/>
|
||||
<td v-if="activeColumns.createdBy">
|
||||
<user
|
||||
:avatar-size="27"
|
||||
:show-username="false"
|
||||
:user="t.createdBy"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:total-pages="totalPages"
|
||||
:current-page="currentPage"
|
||||
/>
|
||||
</card>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {toRef, computed, type Ref} from 'vue'
|
||||
|
||||
import {useStorage} from '@vueuse/core'
|
||||
|
||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||
import Done from '@/components/misc/Done.vue'
|
||||
import User from '@/components/misc/user.vue'
|
||||
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
|
||||
import Labels from '@/components/tasks/partials/labels.vue'
|
||||
import DateTableCell from '@/components/tasks/partials/date-table-cell.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import Sort from '@/components/tasks/partials/sort.vue'
|
||||
import FilterPopup from '@/components/project/partials/filter-popup.vue'
|
||||
import Pagination from '@/components/misc/pagination.vue'
|
||||
import Popup from '@/components/misc/popup.vue'
|
||||
|
||||
import {useTaskList, SortBy} from '@/composables/useTaskList'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
const ACTIVE_COLUMNS_DEFAULT = {
|
||||
index: true,
|
||||
done: true,
|
||||
title: true,
|
||||
priority: false,
|
||||
labels: true,
|
||||
assignees: true,
|
||||
dueDate: true,
|
||||
startDate: false,
|
||||
endDate: false,
|
||||
percentDone: false,
|
||||
created: false,
|
||||
updated: false,
|
||||
createdBy: false,
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const SORT_BY_DEFAULT: SortBy = {
|
||||
index: 'desc',
|
||||
}
|
||||
|
||||
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
|
||||
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
|
||||
|
||||
const taskList = useTaskList(toRef(props, 'projectId'), sortBy.value)
|
||||
|
||||
const {
|
||||
loading,
|
||||
params,
|
||||
totalPages,
|
||||
currentPage,
|
||||
sortByParam,
|
||||
} = taskList
|
||||
const tasks: Ref<ITask[]> = taskList.tasks
|
||||
|
||||
Object.assign(params.value, {
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
})
|
||||
|
||||
// FIXME: by doing this we can have multiple sort orders
|
||||
function sort(property: keyof SortBy) {
|
||||
const order = sortBy.value[property]
|
||||
if (typeof order === 'undefined' || order === 'none') {
|
||||
sortBy.value[property] = 'desc'
|
||||
} else if (order === 'desc') {
|
||||
sortBy.value[property] = 'asc'
|
||||
} else {
|
||||
delete sortBy.value[property]
|
||||
}
|
||||
sortByParam.value = sortBy.value
|
||||
}
|
||||
|
||||
// TODO: re-enable opening task detail in modal
|
||||
// const router = useRouter()
|
||||
const taskDetailRoutes = computed(() => Object.fromEntries(
|
||||
tasks.value.map(({id}) => ([
|
||||
id,
|
||||
{
|
||||
name: 'task.detail',
|
||||
params: {id},
|
||||
// state: { backdropView: router.currentRoute.value.fullPath },
|
||||
},
|
||||
])),
|
||||
))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table {
|
||||
background: transparent;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.columns-filter {
|
||||
margin: 0;
|
||||
|
||||
&.is-open {
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.link-share-view .card {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
127
src/views/project/helpers/useGanttFilters.ts
Normal file
127
src/views/project/helpers/useGanttFilters.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import type {Ref} from 'vue'
|
||||
import type {RouteLocationNormalized, RouteLocationRaw} from 'vue-router'
|
||||
|
||||
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||
import {parseDateProp} from '@/helpers/time/parseDateProp'
|
||||
import {parseBooleanProp} from '@/helpers/time/parseBooleanProp'
|
||||
import {useRouteFilters} from '@/composables/useRouteFilters'
|
||||
import {useGanttTaskList} from './useGanttTaskList'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {GetAllTasksParams} from '@/services/taskCollection'
|
||||
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {DateKebab} from '@/types/DateKebab'
|
||||
|
||||
// convenient internal filter object
|
||||
export interface GanttFilters {
|
||||
projectId: IProject['id']
|
||||
dateFrom: DateISO
|
||||
dateTo: DateISO
|
||||
showTasksWithoutDates: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_SHOW_TASKS_WITHOUT_DATES = false
|
||||
|
||||
const DEFAULT_DATEFROM_DAY_OFFSET = -15
|
||||
const DEFAULT_DATETO_DAY_OFFSET = +55
|
||||
|
||||
const now = new Date()
|
||||
|
||||
function getDefaultDateFrom() {
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET).toISOString()
|
||||
}
|
||||
|
||||
function getDefaultDateTo() {
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATETO_DAY_OFFSET).toISOString()
|
||||
}
|
||||
|
||||
// FIXME: use zod for this
|
||||
function ganttRouteToFilters(route: Partial<RouteLocationNormalized>): GanttFilters {
|
||||
const ganttRoute = route
|
||||
return {
|
||||
projectId: Number(ganttRoute.params?.projectId),
|
||||
dateFrom: parseDateProp(ganttRoute.query?.dateFrom as DateKebab) || getDefaultDateFrom(),
|
||||
dateTo: parseDateProp(ganttRoute.query?.dateTo as DateKebab) || getDefaultDateTo(),
|
||||
showTasksWithoutDates: parseBooleanProp(ganttRoute.query?.showTasksWithoutDates as string) || DEFAULT_SHOW_TASKS_WITHOUT_DATES,
|
||||
}
|
||||
}
|
||||
|
||||
function ganttGetDefaultFilters(route: Partial<RouteLocationNormalized>): GanttFilters {
|
||||
return ganttRouteToFilters({params: {projectId: route.params?.projectId as string}})
|
||||
}
|
||||
|
||||
// FIXME: use zod for this
|
||||
function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
|
||||
let query: Record<string, string> = {}
|
||||
if (
|
||||
filters.dateFrom !== getDefaultDateFrom() ||
|
||||
filters.dateTo !== getDefaultDateTo()
|
||||
) {
|
||||
query = {
|
||||
dateFrom: isoToKebabDate(filters.dateFrom),
|
||||
dateTo: isoToKebabDate(filters.dateTo),
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.showTasksWithoutDates) {
|
||||
query.showTasksWithoutDates = String(filters.showTasksWithoutDates)
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'project.gantt',
|
||||
params: {projectId: filters.projectId},
|
||||
query,
|
||||
}
|
||||
}
|
||||
|
||||
function ganttFiltersToApiParams(filters: GanttFilters): GetAllTasksParams {
|
||||
return {
|
||||
sort_by: ['start_date', 'done', 'id'],
|
||||
order_by: ['asc', 'asc', 'desc'],
|
||||
filter_by: ['start_date', 'start_date'],
|
||||
filter_comparator: ['greater_equals', 'less_equals'],
|
||||
filter_value: [isoToKebabDate(filters.dateFrom), isoToKebabDate(filters.dateTo)],
|
||||
filter_concat: 'and',
|
||||
filter_include_nulls: filters.showTasksWithoutDates,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseGanttFiltersReturn =
|
||||
ReturnType<typeof useRouteFilters<GanttFilters>> &
|
||||
ReturnType<typeof useGanttTaskList<GanttFilters>>
|
||||
|
||||
export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFiltersReturn {
|
||||
const {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
setDefaultFilters,
|
||||
} = useRouteFilters<GanttFilters>(
|
||||
route,
|
||||
ganttGetDefaultFilters,
|
||||
ganttRouteToFilters,
|
||||
ganttFiltersToRoute,
|
||||
)
|
||||
|
||||
const {
|
||||
tasks,
|
||||
loadTasks,
|
||||
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams)
|
||||
|
||||
return {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
setDefaultFilters,
|
||||
|
||||
tasks,
|
||||
loadTasks,
|
||||
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
}
|
||||
}
|
102
src/views/project/helpers/useGanttTaskList.ts
Normal file
102
src/views/project/helpers/useGanttTaskList.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import {computed, ref, shallowReactive, watch, type Ref} from 'vue'
|
||||
import {klona} from 'klona/lite'
|
||||
|
||||
import type {Filters} from '@/composables/useRouteFilters'
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
|
||||
import TaskCollectionService, {type GetAllTasksParams} from '@/services/taskCollection'
|
||||
import TaskService from '@/services/task'
|
||||
|
||||
import TaskModel from '@/models/task'
|
||||
import {error, success} from '@/message'
|
||||
|
||||
// FIXME: unify with general `useTaskList`
|
||||
export function useGanttTaskList<F extends Filters>(
|
||||
filters: Ref<F>,
|
||||
filterToApiParams: (filters: F) => GetAllTasksParams,
|
||||
options: {
|
||||
loadAll?: boolean,
|
||||
} = {
|
||||
loadAll: true,
|
||||
}) {
|
||||
const taskCollectionService = shallowReactive(new TaskCollectionService())
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
|
||||
const isLoading = computed(() => taskCollectionService.loading)
|
||||
|
||||
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
|
||||
|
||||
async function fetchTasks(params: GetAllTasksParams, page = 1): Promise<ITask[]> {
|
||||
const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[]
|
||||
if (options.loadAll && page < taskCollectionService.totalPages) {
|
||||
const nextTasks = await fetchTasks(params, page + 1)
|
||||
return tasks.concat(nextTasks)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and assign new tasks
|
||||
* Normally there is no need to trigger this manually
|
||||
*/
|
||||
async function loadTasks() {
|
||||
const params: GetAllTasksParams = filterToApiParams(filters.value)
|
||||
|
||||
const loadedTasks = await fetchTasks(params)
|
||||
tasks.value = new Map()
|
||||
loadedTasks.forEach(t => tasks.value.set(t.id, t))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tasks when filters change
|
||||
*/
|
||||
watch(
|
||||
filters,
|
||||
() => loadTasks(),
|
||||
{immediate: true, deep: true},
|
||||
)
|
||||
|
||||
async function addTask(task: Partial<ITask>) {
|
||||
const newTask = await taskService.create(new TaskModel({...task}))
|
||||
tasks.value.set(newTask.id, newTask)
|
||||
|
||||
return newTask
|
||||
}
|
||||
|
||||
async function updateTask(task: ITaskPartialWithId) {
|
||||
const oldTask = klona(tasks.value.get(task.id))
|
||||
|
||||
if (!oldTask) return
|
||||
|
||||
// we extend the task with potentially missing info
|
||||
const newTask: ITask = {
|
||||
...oldTask,
|
||||
...task,
|
||||
}
|
||||
|
||||
// set in expectation that server update works
|
||||
tasks.value.set(newTask.id, newTask)
|
||||
|
||||
try {
|
||||
const updatedTask = await taskService.update(newTask)
|
||||
// update the task with possible changes from server
|
||||
tasks.value.set(updatedTask.id, updatedTask)
|
||||
success('Saved')
|
||||
} catch(e: any) {
|
||||
error('Something went wrong saving the task')
|
||||
// roll back changes
|
||||
tasks.value.set(task.id, oldTask)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
tasks,
|
||||
|
||||
isLoading,
|
||||
loadTasks,
|
||||
|
||||
addTask,
|
||||
updateTask,
|
||||
}
|
||||
}
|
@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<modal
|
||||
@close="$router.back()"
|
||||
@submit="archiveList()"
|
||||
@submit="archiveProject()"
|
||||
>
|
||||
<template #header><span>{{ list.isArchived ? $t('list.archive.unarchive') : $t('list.archive.archive') }}</span></template>
|
||||
<template #header><span>{{ project.isArchived ? $t('project.archive.unarchive') : $t('project.archive.archive') }}</span></template>
|
||||
|
||||
<template #text>
|
||||
<p>{{ list.isArchived ? $t('list.archive.unarchiveText') : $t('list.archive.archiveText') }}</p>
|
||||
<p>{{ project.isArchived ? $t('project.archive.unarchiveText') : $t('project.archive.archiveText') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'list-setting-archive'}
|
||||
export default {name: 'project-setting-archive'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -24,24 +24,24 @@ import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const listStore = useListStore()
|
||||
const projectStore = useProjectStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const list = computed(() => listStore.getListById(route.params.listId))
|
||||
useTitle(() => t('list.archive.title', {list: list.value.title}))
|
||||
const project = computed(() => projectStore.getProjectById(route.params.projectId))
|
||||
useTitle(() => t('project.archive.title', {project: project.value.title}))
|
||||
|
||||
async function archiveList() {
|
||||
async function archiveProject() {
|
||||
try {
|
||||
const newList = await listStore.updateList({
|
||||
...list.value,
|
||||
isArchived: !list.value.isArchived,
|
||||
const newProject = await projectStore.updateProject({
|
||||
...project.value,
|
||||
isArchived: !project.value.isArchived,
|
||||
})
|
||||
useBaseStore().setCurrentList(newList)
|
||||
success({message: t('list.archive.success')})
|
||||
useBaseStore().setCurrentProject(newProject)
|
||||
success({message: t('project.archive.success')})
|
||||
} finally {
|
||||
router.back()
|
||||
}
|
||||
|
298
src/views/project/settings/background.vue
Normal file
298
src/views/project/settings/background.vue
Normal file
@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<create-edit
|
||||
v-if="uploadBackgroundEnabled || unsplashBackgroundEnabled"
|
||||
:title="$t('project.background.title')"
|
||||
:loading="backgroundService.loading"
|
||||
class="project-background-setting"
|
||||
:wide="true"
|
||||
>
|
||||
<div class="mb-4" v-if="uploadBackgroundEnabled">
|
||||
<input
|
||||
@change="uploadBackground"
|
||||
accept="image/*"
|
||||
class="is-hidden"
|
||||
ref="backgroundUploadInput"
|
||||
type="file"
|
||||
/>
|
||||
<x-button
|
||||
:loading="backgroundUploadService.loading"
|
||||
@click="backgroundUploadInput?.click()"
|
||||
variant="primary"
|
||||
>
|
||||
{{ $t('project.background.upload') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<template v-if="unsplashBackgroundEnabled">
|
||||
<input
|
||||
:class="{'is-loading': backgroundService.loading}"
|
||||
@keyup="debounceNewBackgroundSearch()"
|
||||
class="input is-expanded"
|
||||
:placeholder="$t('project.background.searchPlaceholder')"
|
||||
type="text"
|
||||
v-model="backgroundSearchTerm"
|
||||
/>
|
||||
|
||||
<p class="unsplash-credit">
|
||||
<BaseButton class="unsplash-credit__link" href="https://unsplash.com">{{ $t('project.background.poweredByUnsplash') }}</BaseButton>
|
||||
</p>
|
||||
|
||||
<ul class="image-search__result-list">
|
||||
<li
|
||||
v-for="im in backgroundSearchResult"
|
||||
class="image-search__result-item"
|
||||
:key="im.id"
|
||||
:style="{'background-image': `url(${backgroundBlurHashes[im.id]})`}"
|
||||
>
|
||||
<CustomTransition name="fade">
|
||||
<BaseButton
|
||||
v-if="backgroundThumbs[im.id]"
|
||||
class="image-search__image-button"
|
||||
@click="setBackground(im.id)"
|
||||
>
|
||||
<img class="image-search__image" :src="backgroundThumbs[im.id]" alt="" />
|
||||
</BaseButton>
|
||||
</CustomTransition>
|
||||
|
||||
<BaseButton
|
||||
:href="`https://unsplash.com/@${im.info.author}`"
|
||||
class="image-search__info"
|
||||
>
|
||||
{{ im.info.authorName }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<x-button
|
||||
v-if="backgroundSearchResult.length > 0"
|
||||
:disabled="backgroundService.loading"
|
||||
@click="searchBackgrounds(currentPage + 1)"
|
||||
class="is-load-more-button mt-4"
|
||||
:shadow="false"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ backgroundService.loading ? $t('misc.loading') : $t('project.background.loadMore') }}
|
||||
</x-button>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<x-button
|
||||
v-if="hasBackground"
|
||||
:shadow="false"
|
||||
variant="tertiary"
|
||||
class="is-danger"
|
||||
@click.prevent.stop="removeBackground"
|
||||
>
|
||||
{{ $t('project.background.remove') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
@click.prevent.stop="$router.back()"
|
||||
>
|
||||
{{ $t('misc.close') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</create-edit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'project-setting-background' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
|
||||
import BackgroundUploadService from '@/services/backgroundUpload'
|
||||
import ProjectService from '@/services/project'
|
||||
import type BackgroundImageModel from '@/models/backgroundImage'
|
||||
|
||||
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import {success} from '@/message'
|
||||
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const baseStore = useBaseStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTitle(() => t('project.background.title'))
|
||||
|
||||
const backgroundService = shallowReactive(new BackgroundUnsplashService())
|
||||
const backgroundSearchTerm = ref('')
|
||||
const backgroundSearchResult = ref([])
|
||||
const backgroundThumbs = ref<Record<string, string>>({})
|
||||
const backgroundBlurHashes = ref<Record<string, string>>({})
|
||||
const currentPage = ref(1)
|
||||
|
||||
// We're using debounce to not search on every keypress but with a delay.
|
||||
const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNCE, {
|
||||
trailing: true,
|
||||
})
|
||||
|
||||
const backgroundUploadService = ref(new BackgroundUploadService())
|
||||
const projectService = ref(new ProjectService())
|
||||
const projectStore = useProjectStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash'))
|
||||
const uploadBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('upload'))
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const hasBackground = computed(() => baseStore.background !== null)
|
||||
|
||||
// Show the default collection of backgrounds
|
||||
newBackgroundSearch()
|
||||
|
||||
function newBackgroundSearch() {
|
||||
if (!unsplashBackgroundEnabled.value) {
|
||||
return
|
||||
}
|
||||
// This is an extra method to reset a few things when searching to not break loading more photos.
|
||||
backgroundSearchResult.value = []
|
||||
backgroundThumbs.value = {}
|
||||
searchBackgrounds()
|
||||
}
|
||||
|
||||
async function searchBackgrounds(page = 1) {
|
||||
currentPage.value = page
|
||||
const result = await backgroundService.getAll({}, {s: backgroundSearchTerm.value, p: page})
|
||||
backgroundSearchResult.value = backgroundSearchResult.value.concat(result)
|
||||
result.forEach((background: BackgroundImageModel) => {
|
||||
getBlobFromBlurHash(background.blurHash)
|
||||
.then((b) => {
|
||||
backgroundBlurHashes.value[background.id] = window.URL.createObjectURL(b)
|
||||
})
|
||||
|
||||
backgroundService.thumb(background).then(b => {
|
||||
backgroundThumbs.value[background.id] = b
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
async function setBackground(backgroundId: string) {
|
||||
// Don't set a background if we're in the process of setting one
|
||||
if (backgroundService.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
const project = await backgroundService.update({
|
||||
id: backgroundId,
|
||||
projectId: route.params.projectId,
|
||||
})
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
namespaceStore.setProjectInNamespaceById(project)
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.success')})
|
||||
}
|
||||
|
||||
const backgroundUploadInput = ref<HTMLInputElement | null>(null)
|
||||
async function uploadBackground() {
|
||||
if (backgroundUploadInput.value?.files?.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const project = await backgroundUploadService.value.create(
|
||||
route.params.projectId,
|
||||
backgroundUploadInput.value?.files[0],
|
||||
)
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
namespaceStore.setProjectInNamespaceById(project)
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.success')})
|
||||
}
|
||||
|
||||
async function removeBackground() {
|
||||
const project = await projectService.value.removeBackground(currentProject.value)
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
namespaceStore.setProjectInNamespaceById(project)
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.removeSuccess')})
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.unsplash-credit {
|
||||
text-align: right;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.unsplash-credit__link {
|
||||
color: var(--grey-800);
|
||||
}
|
||||
|
||||
.image-search__result-list {
|
||||
--items-per-row: 1;
|
||||
margin: 1rem 0 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(var(--items-per-row), 1fr);
|
||||
|
||||
@media screen and (min-width: $mobile) {
|
||||
--items-per-row: 2;
|
||||
}
|
||||
@media screen and (min-width: $tablet) {
|
||||
--items-per-row: 4;
|
||||
}
|
||||
@media screen and (min-width: $tablet) {
|
||||
--items-per-row: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.image-search__result-item {
|
||||
margin-top: 0; // FIXME: removes padding from .content
|
||||
aspect-ratio: 16 / 10;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-search__image-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-search__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-search__info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: .25rem 0;
|
||||
opacity: 0;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
font-size: .75rem;
|
||||
font-weight: bold;
|
||||
color: var(--white);
|
||||
transition: opacity $transition;
|
||||
}
|
||||
.image-search__result-item:hover .image-search__info {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.is-load-more-button {
|
||||
margin: 1rem auto 0 !important;
|
||||
display: block;
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
72
src/views/project/settings/delete.vue
Normal file
72
src/views/project/settings/delete.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<modal
|
||||
@close="$router.back()"
|
||||
@submit="deleteProject()"
|
||||
>
|
||||
<template #header><span>{{ $t('project.delete.header') }}</span></template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('project.delete.text1') }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong v-if="totalTasks !== null" class="has-text-white">
|
||||
{{
|
||||
totalTasks > 0 ? $t('project.delete.tasksToDelete', {count: totalTasks}) : $t('project.delete.noTasksToDelete')
|
||||
}}
|
||||
</strong>
|
||||
<Loading v-else class="is-loading-small"/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ $t('misc.cannotBeUndone') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watchEffect} from 'vue'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {success} from '@/message'
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const projectStore = useProjectStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const totalTasks = ref<number | null>(null)
|
||||
|
||||
const project = computed(() => projectStore.getProjectById(route.params.projectId))
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (!route.params.projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskCollectionService = new TaskCollectionService()
|
||||
taskCollectionService.getAll({projectId: route.params.projectId}).then(() => {
|
||||
totalTasks.value = taskCollectionService.totalPages * taskCollectionService.resultCount
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
useTitle(() => t('project.delete.title', {project: project?.value?.title}))
|
||||
|
||||
async function deleteProject() {
|
||||
if (!project.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await projectStore.deleteProject(project.value)
|
||||
success({message: t('project.delete.success')})
|
||||
router.push({name: 'home'})
|
||||
}
|
||||
</script>
|
75
src/views/project/settings/duplicate.vue
Normal file
75
src/views/project/settings/duplicate.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<create-edit
|
||||
:title="$t('project.duplicate.title')"
|
||||
primary-icon="paste"
|
||||
:primary-label="$t('project.duplicate.label')"
|
||||
@primary="duplicateProject"
|
||||
:loading="projectDuplicateService.loading"
|
||||
>
|
||||
<p>{{ $t('project.duplicate.text') }}</p>
|
||||
|
||||
<Multiselect
|
||||
:placeholder="$t('namespace.search')"
|
||||
@search="findNamespaces"
|
||||
:search-results="namespaces"
|
||||
@select="selectNamespace"
|
||||
label="title"
|
||||
:search-delay="10"
|
||||
/>
|
||||
</create-edit>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import ProjectDuplicateService from '@/services/projectDuplicateService'
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useNamespaceSearch} from '@/composables/useNamespaceSearch'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('project.duplicate.title'))
|
||||
|
||||
const {
|
||||
namespaces,
|
||||
findNamespaces,
|
||||
} = useNamespaceSearch()
|
||||
|
||||
const selectedNamespace = ref<INamespace>()
|
||||
|
||||
function selectNamespace(namespace: INamespace) {
|
||||
selectedNamespace.value = namespace
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
|
||||
const projectDuplicateService = shallowReactive(new ProjectDuplicateService())
|
||||
|
||||
async function duplicateProject() {
|
||||
const projectDuplicate = new ProjectDuplicateModel({
|
||||
// FIXME: should be parameter
|
||||
projectId: route.params.projectId,
|
||||
namespaceId: selectedNamespace.value?.id,
|
||||
})
|
||||
|
||||
const duplicate = await projectDuplicateService.create(projectDuplicate)
|
||||
|
||||
namespaceStore.addProjectToNamespace(duplicate.project)
|
||||
projectStore.setProject(duplicate.project)
|
||||
success({message: t('project.duplicate.success')})
|
||||
router.push({name: 'project.index', params: {projectId: duplicate.project.id}})
|
||||
}
|
||||
</script>
|
108
src/views/project/settings/edit.vue
Normal file
108
src/views/project/settings/edit.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<create-edit
|
||||
:title="$t('project.edit.header')"
|
||||
primary-icon=""
|
||||
:primary-label="$t('misc.save')"
|
||||
@primary="save"
|
||||
:tertiary="$t('misc.delete')"
|
||||
@tertiary="$router.push({ name: 'project.settings.delete', params: { id: projectId } })"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label" for="title">{{ $t('project.title') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
:class="{ 'disabled': isLoading}"
|
||||
:disabled="isLoading || undefined"
|
||||
@keyup.enter="save"
|
||||
class="input"
|
||||
id="title"
|
||||
:placeholder="$t('project.edit.titlePlaceholder')"
|
||||
type="text"
|
||||
v-focus
|
||||
v-model="project.title"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="identifier"
|
||||
v-tooltip="$t('project.edit.identifierTooltip')">
|
||||
{{ $t('project.edit.identifier') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
:class="{ 'disabled': isLoading}"
|
||||
:disabled="isLoading || undefined"
|
||||
@keyup.enter="save"
|
||||
class="input"
|
||||
id="identifier"
|
||||
:placeholder="$t('project.edit.identifierPlaceholder')"
|
||||
type="text"
|
||||
v-focus
|
||||
v-model="project.identifier"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="projectdescription">{{ $t('project.edit.description') }}</label>
|
||||
<div class="control">
|
||||
<Editor
|
||||
:class="{ 'disabled': isLoading}"
|
||||
:disabled="isLoading"
|
||||
:previewIsDefault="false"
|
||||
id="projectdescription"
|
||||
:placeholder="$t('project.edit.descriptionPlaceholder')"
|
||||
v-model="project.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.edit.color') }}</label>
|
||||
<div class="control">
|
||||
<color-picker v-model="project.hexColor"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</create-edit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'project-setting-edit' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProject} from '@/stores/projects'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number as PropType<IProject['id']>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const {project, save: saveProject, isLoading} = useProject(props.projectId)
|
||||
|
||||
useTitle(() => project?.title ? t('project.edit.title', {project: project.title}) : '')
|
||||
|
||||
async function save() {
|
||||
await saveProject()
|
||||
await useBaseStore().handleSetCurrentProject({project})
|
||||
router.back()
|
||||
}
|
||||
</script>
|
@ -1,29 +1,29 @@
|
||||
<template>
|
||||
<create-edit
|
||||
:title="$t('list.share.header')"
|
||||
:title="$t('project.share.header')"
|
||||
:has-primary-action="false"
|
||||
>
|
||||
<template v-if="list">
|
||||
<template v-if="project">
|
||||
<userTeam
|
||||
:id="list.id"
|
||||
:id="project.id"
|
||||
:userIsAdmin="userIsAdmin"
|
||||
shareType="user"
|
||||
type="list"
|
||||
type="project"
|
||||
/>
|
||||
<userTeam
|
||||
:id="list.id"
|
||||
:id="project.id"
|
||||
:userIsAdmin="userIsAdmin"
|
||||
shareType="team"
|
||||
type="list"
|
||||
type="project"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<link-sharing :list-id="listId" v-if="linkSharingEnabled" class="mt-4"/>
|
||||
<link-sharing :project-id="projectId" v-if="linkSharingEnabled" class="mt-4"/>
|
||||
</create-edit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'list-setting-share'}
|
||||
export default {name: 'project-setting-share'}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@ -32,9 +32,9 @@ import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useTitle} from '@vueuse/core'
|
||||
|
||||
import ListService from '@/services/list'
|
||||
import ListModel from '@/models/list'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import ProjectService from '@/services/project'
|
||||
import ProjectModel from '@/models/project'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {RIGHTS} from '@/constants/rights'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
@ -46,9 +46,9 @@ import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const list = ref<IList>()
|
||||
const title = computed(() => list.value?.title
|
||||
? t('list.share.title', {list: list.value.title})
|
||||
const project = ref<IProject>()
|
||||
const title = computed(() => project.value?.title
|
||||
? t('project.share.title', {project: project.value.title})
|
||||
: '',
|
||||
)
|
||||
useTitle(title)
|
||||
@ -56,19 +56,19 @@ useTitle(title)
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const linkSharingEnabled = computed(() => configStore.linkSharingEnabled)
|
||||
const userIsAdmin = computed(() => list?.value?.maxRight === RIGHTS.ADMIN)
|
||||
const userIsAdmin = computed(() => project?.value?.maxRight === RIGHTS.ADMIN)
|
||||
|
||||
async function loadList(listId: number) {
|
||||
const listService = new ListService()
|
||||
const newList = await listService.get(new ListModel({id: listId}))
|
||||
await useBaseStore().handleSetCurrentList({list: newList})
|
||||
list.value = newList
|
||||
async function loadProject(projectId: number) {
|
||||
const projectService = new ProjectService()
|
||||
const newProject = await projectService.get(new ProjectModel({id: projectId}))
|
||||
await useBaseStore().handleSetCurrentProject({project: newProject})
|
||||
project.value = newProject
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const listId = computed(() => route.params.listId !== undefined
|
||||
? parseInt(route.params.listId as string)
|
||||
const projectId = computed(() => route.params.projectId !== undefined
|
||||
? parseInt(route.params.projectId as string)
|
||||
: undefined,
|
||||
)
|
||||
watchEffect(() => listId.value !== undefined && loadList(listId.value))
|
||||
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
|
||||
</script>
|
||||
|
Reference in New Issue
Block a user