1
0

chore: move frontend files

This commit is contained in:
kolaente
2024-02-07 14:56:56 +01:00
parent 447641c222
commit fc4676315d
606 changed files with 0 additions and 0 deletions

View File

@ -0,0 +1,109 @@
<template>
<div
v-cy="'projects-list'"
class="content loader-container"
:class="{'is-loading': loading}"
>
<header class="project-header">
<Fancycheckbox
v-model="showArchived"
v-cy="'show-archived-check'"
>
{{ $t('project.showArchived') }}
</Fancycheckbox>
<div class="action-buttons">
<x-button
:to="{name: 'filters.create'}"
icon="filter"
>
{{ $t('filters.create.title') }}
</x-button>
<x-button
v-cy="'new-project'"
:to="{name: 'project.create'}"
icon="plus"
>
{{ $t('project.create.header') }}
</x-button>
</div>
</header>
<ProjectCardGrid
:projects="projects"
:show-archived="showArchived"
/>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import ProjectCardGrid from '@/components/project/partials/ProjectCardGrid.vue'
import {useTitle} from '@/composables/useTitle'
import {useStorage} from '@vueuse/core'
import {useProjectStore} from '@/stores/projects'
const {t} = useI18n()
const projectStore = useProjectStore()
useTitle(() => t('project.title'))
const showArchived = useStorage('showArchived', false)
const loading = computed(() => projectStore.isLoading)
const projects = computed(() => {
return showArchived.value
? projectStore.projectsArray
: projectStore.projectsArray.filter(({isArchived}) => !isArchived)
})
</script>
<style lang="scss" scoped>
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
@media screen and (max-width: $tablet) {
flex-direction: column;
}
}
.action-buttons {
display: flex;
justify-content: space-between;
gap: 1rem;
@media screen and (max-width: $tablet) {
width: 100%;
flex-direction: column;
align-items: stretch;
}
}
.project:not(:first-child) {
margin-top: 1rem;
}
.project-title {
display: flex;
align-items: center;
}
.is-archived {
font-size: 0.75rem;
border: 1px solid var(--grey-500);
color: $grey !important;
padding: 2px 4px;
border-radius: 3px;
font-family: $vikunja-font;
background: var(--white-translucent);
margin-left: .5rem;
}
</style>

View File

@ -0,0 +1,102 @@
<template>
<CreateEdit
:title="$t('project.create.header')"
:primary-disabled="project.title === ''"
@create="createNewProject()"
>
<div class="field">
<label
class="label"
for="projectTitle"
>{{ $t('project.title') }}</label>
<div
:class="{ 'is-loading': projectService.loading }"
class="control"
>
<input
v-model="project.title"
v-focus
:class="{ disabled: projectService.loading }"
class="input"
:placeholder="$t('project.create.titlePlaceholder')"
type="text"
name="projectTitle"
@keyup.enter="createNewProject()"
@keyup.esc="$router.back()"
>
</div>
</div>
<p
v-if="showError && project.title === ''"
class="help is-danger"
>
{{ $t('project.create.addTitleRequired') }}
</p>
<div
v-if="projectStore.hasProjects"
class="field"
>
<label class="label">{{ $t('project.parent') }}</label>
<div class="control">
<ProjectSearch v-model="parentProject" />
</div>
</div>
<div class="field">
<label class="label">{{ $t('project.color') }}</label>
<div class="control">
<ColorPicker v-model="project.hexColor" />
</div>
</div>
</CreateEdit>
</template>
<script setup lang="ts">
import {ref, reactive, shallowReactive, watch} from 'vue'
import {useI18n} from 'vue-i18n'
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 {useProjectStore} from '@/stores/projects'
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
import type {IProject} from '@/modelTypes/IProject'
const props = defineProps<{
parentProjectId?: number,
}>()
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('project.create.header'))
const showError = ref(false)
const project = reactive(new ProjectModel())
const projectService = shallowReactive(new ProjectService())
const projectStore = useProjectStore()
const parentProject = ref<IProject | null>(null)
watch(
() => props.parentProjectId,
() => parentProject.value = projectStore.projects[props.parentProjectId],
{immediate: true},
)
async function createNewProject() {
if (project.title === '') {
showError.value = true
return
}
showError.value = false
if (parentProject.value) {
project.parentProjectId = parentProject.value.id
}
await projectStore.createProject(project)
success({message: t('project.create.createdSuccess')})
}
</script>

View File

@ -0,0 +1,219 @@
<template>
<ProjectWrapper
class="project-gantt"
:project-id="filters.projectId"
view-name="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
id="range"
ref="flatPickerEl"
v-model="flatPickerDateRange"
:config="flatPickerConfig"
class="input"
:placeholder="$t('project.gantt.range')"
/>
</div>
</div>
<div
v-if="!hasDefaultFilters"
class="field"
>
<label
class="label"
for="range"
>Reset</label>
<div class="control">
<x-button @click="setDefaultFilters">
Reset
</x-button>
</div>
</div>
<Fancycheckbox
v-model="filters.showTasksWithoutDates"
is-block
>
{{ $t('project.gantt.showTasksWithoutDates') }}
</Fancycheckbox>
</div>
</card>
</template>
<template #default>
<div class="gantt-chart-container">
<card
:has-content="false"
:padding="false"
class="has-overflow"
>
<GanttChart
:filters="filters"
:tasks="tasks"
:is-loading="isLoading"
:default-task-start-date="defaultTaskStartDate"
:default-task-end-date="defaultTaskEndDate"
@update:task="updateTask"
/>
<TaskForm
v-if="canWrite"
@createTask="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 { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
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 props = defineProps<{route: RouteLocationNormalized}>()
const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttChart.vue'))
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 flatPickerConfig = computed<Options>(() => ({
altFormat: t('date.altFormatShort'),
altInput: true,
defaultDate: initialDateRange,
enableTime: false,
mode: 'range',
locale: getFlatpickrLanguage(),
}))
</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>

View File

@ -0,0 +1,45 @@
<template>
<modal
@close="$router.back()"
>
<card
:title="project?.title"
>
<div
v-if="htmlDescription !== ''"
class="has-text-left"
v-html="htmlDescription"
/>
<p
v-else
class="is-italic"
>
{{ $t('project.noDescriptionAvailable') }}
</p>
</card>
</modal>
</template>
<script lang="ts" setup>
import {computed} from 'vue'
import DOMPurify from 'dompurify'
import {useProjectStore} from '@/stores/projects'
const props = defineProps({
projectId: {
type: Number,
required: true,
},
})
const projectStore = useProjectStore()
const project = computed(() => projectStore.projects[props.projectId])
const htmlDescription = computed(() => {
const description = project.value?.description || ''
if (description === '') {
return ''
}
return DOMPurify.sanitize(description, {ADD_ATTR: ['target']})
})
</script>

View File

@ -0,0 +1,873 @@
<template>
<ProjectWrapper
class="project-kanban"
:project-id="projectId"
view-name="kanban"
>
<template #header>
<div
v-if="!isSavedFilter(project)"
class="filter-container"
>
<div class="items">
<FilterPopup 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"
:model-value="buckets"
group="buckets"
:disabled="!canWrite || newTaskInputFocused"
tag="ul"
:item-key="({id}: IBucket) => `bucket${id}`"
:component-data="bucketDraggableComponentData"
@update:modelValue="updateBuckets"
@end="updateBucketPosition"
@start="() => dragBucket = true"
>
<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="project?.doneBucketId === bucket.id"
v-tooltip="$t('project.kanban.doneBucketHint')"
class="icon is-small has-text-success mr-2"
>
<icon icon="check-double" />
</span>
<h2
class="title input"
:contenteditable="(bucketTitleEditable && canWrite && !collapsedBuckets[bucket.id]) ? true : undefined"
:spellcheck="false"
@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"
>
{{ bucket.title }}
</h2>
<span
v-if="bucket.limit > 0"
:class="{'is-max': bucket.count >= bucket.limit}"
class="limit"
>
{{ bucket.count }}/{{ bucket.limit }}
</span>
<Dropdown
v-if="canWrite && !collapsedBuckets[bucket.id]"
class="is-right options"
trigger-icon="ellipsis-v"
@close="() => showSetLimitInput = false"
>
<DropdownItem
@click.stop="showSetLimitInput = true"
>
<div
v-if="showSetLimitInput"
class="field has-addons"
>
<div class="control">
<input
v-focus.always
:value="bucket.limit"
class="input"
type="number"
min="0"
@keyup.esc="() => showSetLimitInput = false"
@keyup.enter="() => showSetLimitInput = false"
@input="(event) => setBucketLimit(bucket.id, parseInt((event.target as HTMLInputElement).value))"
>
</div>
<div class="control">
<x-button
v-cy="'setBucketLimit'"
:disabled="bucket.limit < 0"
:icon="['far', 'save']"
:shadow="false"
/>
</div>
</div>
<template v-else>
{{
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
}}
</template>
</DropdownItem>
<DropdownItem
v-tooltip="$t('project.kanban.doneBucketHintExtended')"
:icon-class="{'has-text-success': bucket.id === project?.doneBucketId}"
icon="check-double"
@click.stop="toggleDoneBucket(bucket)"
>
{{ $t('project.kanban.doneBucket') }}
</DropdownItem>
<DropdownItem
v-tooltip="$t('project.kanban.defaultBucketHint')"
:icon-class="{'has-text-primary': bucket.id === project.defaultBucketId}"
icon="th"
@click.stop="toggleDefaultBucket(bucket)"
>
{{ $t('project.kanban.defaultBucket') }}
</DropdownItem>
<DropdownItem
icon="angles-up"
@click.stop="() => collapseBucket(bucket)"
>
{{ $t('project.kanban.collapse') }}
</DropdownItem>
<DropdownItem
v-tooltip="buckets.length <= 1 ? $t('project.kanban.deleteLast') : ''"
:class="{'is-disabled': buckets.length <= 1}"
icon-class="has-text-danger"
icon="trash-alt"
@click.stop="() => deleteBucketModal(bucket.id)"
>
{{ $t('misc.delete') }}
</DropdownItem>
</Dropdown>
</div>
<draggable
v-bind="DRAG_OPTIONS"
:model-value="bucket.tasks"
: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)"
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
@start="() => dragstart(bucket)"
@end="updateTaskPosition"
>
<template #footer>
<div
v-if="canWrite"
class="bucket-footer"
>
<div
v-if="showNewTaskInput[bucket.id]"
class="field"
>
<div
class="control"
:class="{'is-loading': loading || taskLoading}"
>
<input
v-model="newTaskText"
v-focus.always
class="input"
:disabled="loading || taskLoading || undefined"
:placeholder="$t('project.kanban.addTaskPlaceholder')"
type="text"
@focusout="toggleShowNewTaskInput(bucket.id)"
@focusin="() => newTaskInputFocused = true"
@keyup.enter="addTaskToBucket(bucket.id)"
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
>
</div>
<p
v-if="newTaskError[bucket.id] && newTaskText === ''"
class="help is-danger"
>
{{ $t('project.create.addTitleRequired') }}
</p>
</div>
<x-button
v-else
class="is-fullwidth has-text-centered"
:shadow="false"
icon="plus"
variant="secondary"
@click="toggleShowNewTaskInput(bucket.id)"
>
{{ bucket.tasks.length === 0 ? $t('project.kanban.addTask') : $t('project.kanban.addAnotherTask') }}
</x-button>
</div>
</template>
<template #item="{element: task}">
<div class="task-item">
<KanbanCard
class="kanban-card"
:task="task"
:loading="taskUpdating[task.id] ?? false"
/>
</div>
</template>
</draggable>
</div>
</template>
</draggable>
<div
v-if="canWrite && !loading && buckets.length > 0"
class="bucket new-bucket"
>
<input
v-if="showNewBucketInput"
v-model="newBucketTitle"
v-focus.always
:class="{'is-loading': loading}"
:disabled="loading || undefined"
class="input"
:placeholder="$t('project.kanban.addBucketPlaceholder')"
type="text"
@blur="() => showNewBucketInput = false"
@keyup.enter="createNewBucket"
@keyup.esc="($event.target as HTMLInputElement).blur()"
>
<x-button
v-else
:shadow="false"
class="is-transparent is-fullwidth has-text-centered"
variant="secondary"
icon="plus"
@click="() => showNewBucketInput = true"
>
{{ $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} 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'
import {useProjectStore} from '@/stores/projects'
const {
projectId = undefined,
} = defineProps<{
projectId: number,
}>()
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 {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
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(() => projectId ? projectStore.projects[projectId]: null)
const buckets = computed(() => kanbanStore.buckets)
const loading = computed(() => kanbanStore.isLoading)
const taskLoading = computed(() => taskStore.isLoading)
watch(
() => ({
params: params.value,
projectId,
}),
({params}) => {
if (projectId === undefined || Number(projectId) === 0) {
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 oldBucket = buckets.value.find(b => b.id === task.bucketId)
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,
)
if (
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
newBucket.id !== oldBucket.id
) {
newTask.done = project.value?.doneBucketId === newBucket.id
}
if (
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
newBucket.id !== oldBucket.id
) {
kanbanStore.setBucketById({
...oldBucket,
count: oldBucket.count - 1,
})
kanbanStore.setBucketById({
...newBucket,
count: newBucket.count + 1,
})
}
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: project.value.id,
})
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: project.value.id,
}))
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: project.value.id,
}),
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.count < bucket.limit
)
}
function dragstart(bucket: IBucket) {
drag.value = true
sourceBucket.value = bucket.id
}
async function toggleDefaultBucket(bucket: IBucket) {
const defaultBucketId = project.value.defaultBucketId === bucket.id
? 0
: bucket.id
await projectStore.updateProject({
...project.value,
defaultBucketId,
})
success({message: t('project.kanban.defaultBucketSavedSuccess')})
}
async function toggleDoneBucket(bucket: IBucket) {
const doneBucketId = project.value?.doneBucketId === bucket.id
? 0
: bucket.id
await projectStore.updateProject({
...project.value,
doneBucketId,
})
success({message: t('project.kanban.doneBucketSavedSuccess')})
}
function collapseBucket(bucket: IBucket) {
collapsedBuckets.value[bucket.id] = true
saveCollapsedBucketState(project.value.id, collapsedBuckets.value)
}
function unCollapseBucket(bucket: IBucket) {
if (!collapsedBuckets.value[bucket.id]) {
return
}
collapsedBuckets.value[bucket.id] = false
saveCollapsedBucketState(project.value.id, 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>

View File

@ -0,0 +1,369 @@
<template>
<ProjectWrapper
class="project-list"
:project-id="projectId"
view-name="project"
>
<template #header>
<div
v-if="!isSavedFilter(project)"
class="filter-container"
>
<div class="items">
<div class="search">
<div
:class="{ hidden: !showTaskSearch }"
class="field has-addons"
>
<div class="control has-icons-left has-icons-right">
<input
v-model="searchTerm"
v-focus
class="input"
:placeholder="$t('misc.search')"
type="text"
@blur="hideSearchBar()"
@keyup.enter="searchTasks"
>
<span class="icon is-left">
<icon icon="search" />
</span>
</div>
<div class="control">
<x-button
:loading="loading"
:shadow="false"
@click="searchTasks"
>
{{ $t('misc.search') }}
</x-button>
</div>
</div>
<x-button
v-if="!showTaskSearch"
icon="search"
variant="secondary"
@click="showTaskSearch = !showTaskSearch"
/>
</div>
<FilterPopup
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"
>
<AddTask
v-if="!project.isArchived && canWrite"
ref="addTaskRef"
class="list-view__add-task d-print-none"
:default-position="firstNewPosition"
@taskAdded="updateTaskList"
/>
<Nothing v-if="ctaVisible && tasks.length === 0 && !loading">
{{ $t('project.list.empty') }}
<ButtonLink
v-if="project.id > 0"
@click="focusNewTaskInput()"
>
{{ $t('project.list.newTaskCta') }}
</ButtonLink>
</Nothing>
<draggable
v-if="tasks && tasks.length > 0"
v-bind="DRAG_OPTIONS"
v-model="tasks"
group="tasks"
handle=".handle"
:disabled="!canWrite"
item-key="id"
tag="ul"
:component-data="{
class: {
tasks: true,
'dragging-disabled': !canWrite || isAlphabeticalSorting
},
type: 'transition-group'
}"
@start="() => drag = true"
@end="saveTaskPosition"
>
<template #item="{element: t}">
<SingleTaskInProject
:show-list-color="false"
:disabled="!canWrite"
:can-mark-as-done="canWrite || isSavedFilter(project)"
:the-task="t"
:all-tasks="allTasks"
@taskUpdated="updateTasks"
>
<template v-if="canWrite">
<span class="icon handle">
<icon icon="grip-lines" />
</span>
</template>
</SingleTaskInProject>
</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, nextTick, onMounted, watch} 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'
const {
projectId,
} = defineProps<{
projectId: IProject['id'],
}>()
const ctaVisible = ref(false)
const showTaskSearch = ref(false)
const drag = ref(false)
const DRAG_OPTIONS = {
animation: 100,
ghostClass: 'task-ghost',
} as const
const {
tasks: allTasks,
loading,
totalPages,
currentPage,
loadTasks,
searchTerm,
params,
sortByParam,
} = useTaskList(() => projectId, {position: 'asc' })
const tasks = ref<ITask[]>([])
watch(
allTasks,
() => {
tasks.value = [...allTasks.value]
if (projectId < 0) {
return
}
const tasksById = {}
tasks.value.forEach(t => tasksById[t.id] = true)
tasks.value = tasks.value.filter(t => {
if (typeof t.relatedTasks?.parenttask === 'undefined') {
return true
}
// If the task is a subtask, make sure the parent task is available in the current view as well
for (const pt of t.relatedTasks.parenttask) {
if(typeof tasksById[pt.id] === 'undefined') {
return true
}
}
return false
})
},
)
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 {
allTasks.value = [
task,
...allTasks.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
}
}
}
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;
}
.control.has-icons-left .icon,
.control.has-icons-right .icon {
transition: all $transition;
}
:deep(.single-task) {
.handle {
opacity: 1;
transition: opacity $transition;
margin-right: .25rem;
cursor: grab;
}
@media(hover: hover) and (pointer: fine) {
& .handle {
opacity: 0;
}
&:hover .handle {
opacity: 1;
}
}
}
</style>

View File

@ -0,0 +1,380 @@
<template>
<ProjectWrapper
class="project-table"
:project-id="projectId"
view-name="table"
>
<template #header>
<div class="filter-container">
<div class="items">
<Popup>
<template #trigger="{toggle}">
<x-button
icon="th"
variant="secondary"
@click.prevent.stop="toggle()"
>
{{ $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>
<FilterPopup 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
v-for="t in tasks"
:key="t.id"
>
<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">
<PriorityLabel
: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">
<AssigneeList
v-if="t.assignees.length > 0"
:assignees="t.assignees"
:avatar-size="28"
class="ml-1"
:inline="true"
/>
</td>
<DateTableCell
v-if="activeColumns.dueDate"
:date="t.dueDate"
/>
<DateTableCell
v-if="activeColumns.startDate"
:date="t.startDate"
/>
<DateTableCell
v-if="activeColumns.endDate"
:date="t.endDate"
/>
<td v-if="activeColumns.percentDone">
{{ t.percentDone * 100 }}%
</td>
<DateTableCell
v-if="activeColumns.created"
:date="t.created"
/>
<DateTableCell
v-if="activeColumns.updated"
:date="t.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 {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} from '@/composables/useTaskList'
import type {SortBy} from '@/composables/useTaskList'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
const {
projectId,
} = defineProps<{
projectId: IProject['id'],
}>()
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 SORT_BY_DEFAULT: SortBy = {
index: 'desc',
}
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
const taskList = useTaskList(() => 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>

View File

@ -0,0 +1,128 @@
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,
['project.gantt'],
)
const {
tasks,
loadTasks,
isLoading,
addTask,
updateTask,
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams)
return {
filters,
hasDefaultFilters,
setDefaultFilters,
tasks,
loadTasks,
isLoading,
addTask,
updateTask,
}
}

View 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) {
error('Something went wrong saving the task')
// roll back changes
tasks.value.set(task.id, oldTask)
}
}
return {
tasks,
isLoading,
loadTasks,
addTask,
updateTask,
}
}

View File

@ -0,0 +1,51 @@
<template>
<modal
@close="$router.back()"
@submit="archiveProject()"
>
<template #header>
<span>{{ project.isArchived ? $t('project.archive.unarchive') : $t('project.archive.archive') }}</span>
</template>
<template #text>
<p>{{ project.isArchived ? $t('project.archive.unarchiveText') : $t('project.archive.archiveText') }}</p>
</template>
</modal>
</template>
<script lang="ts">
export default {name: 'ProjectSettingArchive'}
</script>
<script setup lang="ts">
import {computed} from 'vue'
import {useRouter, useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
const router = useRouter()
const route = useRoute()
const project = computed(() => projectStore.projects[route.params.projectId])
useTitle(() => t('project.archive.title', {project: project.value.title}))
async function archiveProject() {
try {
const newProject = await projectStore.updateProject({
...project.value,
isArchived: !project.value.isArchived,
})
useBaseStore().setCurrentProject(newProject)
success({message: t('project.archive.success')})
} finally {
router.back()
}
}
</script>

View File

@ -0,0 +1,305 @@
<template>
<CreateEdit
v-if="uploadBackgroundEnabled || unsplashBackgroundEnabled"
:title="$t('project.background.title')"
:loading="backgroundService.loading"
class="project-background-setting"
:wide="true"
>
<div
v-if="uploadBackgroundEnabled"
class="mb-4"
>
<input
ref="backgroundUploadInput"
accept="image/*"
class="is-hidden"
type="file"
@change="uploadBackground"
>
<x-button
:loading="backgroundUploadService.loading"
variant="primary"
@click="backgroundUploadInput?.click()"
>
{{ $t('project.background.upload') }}
</x-button>
</div>
<template v-if="unsplashBackgroundEnabled">
<input
v-model="backgroundSearchTerm"
:class="{'is-loading': backgroundService.loading}"
class="input is-expanded"
:placeholder="$t('project.background.searchPlaceholder')"
type="text"
@keyup="debounceNewBackgroundSearch()"
>
<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"
:key="im.id"
class="image-search__result-item"
: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"
class="is-load-more-button mt-4"
:shadow="false"
variant="secondary"
@click="searchBackgrounds(currentPage + 1)"
>
{{ 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>
</CreateEdit>
</template>
<script lang="ts">
export default { name: 'ProjectSettingBackground' }
</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 {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 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})
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})
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})
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: $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>

View File

@ -0,0 +1,79 @@
<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
v-if="totalTasks !== null"
class="has-text-weight-bold"
>
{{
totalTasks > 0 ? $t('project.delete.tasksToDelete', {count: totalTasks}) : $t('project.delete.noTasksToDelete')
}}
</p>
<Loading
v-else
class="is-loading-small"
variant="default"
/>
<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.projects[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>

View File

@ -0,0 +1,48 @@
<template>
<CreateEdit
:title="$t('project.duplicate.title')"
primary-icon="paste"
:primary-label="$t('project.duplicate.label')"
:loading="isLoading"
@primary="duplicate"
>
<p>{{ $t('project.duplicate.text') }}</p>
<ProjectSearch v-model="parentProject" />
</CreateEdit>
</template>
<script setup lang="ts">
import {ref, watch} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import CreateEdit from '@/components/misc/create-edit.vue'
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useProject, useProjectStore} from '@/stores/projects'
import type {IProject} from '@/modelTypes/IProject'
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('project.duplicate.title'))
const route = useRoute()
const projectStore = useProjectStore()
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
const parentProject = ref<IProject | null>(null)
watch(
() => project.parentProjectId,
parentProjectId => {
parentProject.value = projectStore.projects[parentProjectId]
},
{immediate: true},
)
async function duplicate() {
await duplicateProject(parentProject.value?.id ?? 0)
success({message: t('project.duplicate.success')})
}
</script>

View File

@ -0,0 +1,136 @@
<template>
<CreateEdit
:title="$t('project.edit.header')"
primary-icon=""
:primary-label="$t('misc.save')"
:tertiary="$t('misc.delete')"
@primary="save"
@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
id="title"
v-model="project.title"
v-focus
:class="{ 'disabled': isLoading}"
:disabled="isLoading || undefined"
class="input"
:placeholder="$t('project.edit.titlePlaceholder')"
type="text"
@keyup.enter="save"
>
</div>
</div>
<div class="field">
<label
v-tooltip="$t('project.edit.identifierTooltip')"
class="label"
for="identifier"
>
{{ $t('project.edit.identifier') }}
</label>
<div class="control">
<input
id="identifier"
v-model="project.identifier"
v-focus
:class="{ 'disabled': isLoading}"
:disabled="isLoading || undefined"
class="input"
:placeholder="$t('project.edit.identifierPlaceholder')"
type="text"
@keyup.enter="save"
>
</div>
</div>
<div class="field">
<label class="label">{{ $t('project.parent') }}</label>
<div class="control">
<ProjectSearch v-model="parentProject" />
</div>
</div>
<div class="field">
<label
class="label"
for="projectdescription"
>{{ $t('project.edit.description') }}</label>
<div class="control">
<Editor
id="projectdescription"
v-model="project.description"
:class="{ 'disabled': isLoading}"
:disabled="isLoading"
:placeholder="$t('project.edit.descriptionPlaceholder')"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('project.edit.color') }}</label>
<div class="control">
<ColorPicker v-model="project.hexColor" />
</div>
</div>
</CreateEdit>
</template>
<script lang="ts">
export default {name: 'ProjectSettingEdit'}
</script>
<script setup lang="ts">
import {watch, ref, 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 ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
import type {IProject} from '@/modelTypes/IProject'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
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 projectStore = useProjectStore()
const {t} = useI18n({useScope: 'global'})
const {project, save: saveProject, isLoading} = useProject(props.projectId)
const parentProject = ref<IProject | null>(null)
watch(
() => project.parentProjectId,
projectId => {
if (project.parentProjectId) {
parentProject.value = projectStore.projects[project.parentProjectId]
}
},
{immediate: true},
)
useTitle(() => project?.title ? t('project.edit.title', {project: project.title}) : '')
async function save() {
project.parentProjectId = parentProject.value?.id ?? project.parentProjectId
await saveProject()
await useBaseStore().handleSetCurrentProject({project})
router.back()
}
</script>

View File

@ -0,0 +1,78 @@
<template>
<CreateEdit
:title="$t('project.share.header')"
:has-primary-action="false"
>
<template v-if="project">
<userTeam
:id="project.id"
:user-is-admin="userIsAdmin"
share-type="user"
type="project"
/>
<userTeam
:id="project.id"
:user-is-admin="userIsAdmin"
share-type="team"
type="project"
/>
</template>
<LinkSharing
v-if="linkSharingEnabled"
:project-id="projectId"
class="mt-4"
/>
</CreateEdit>
</template>
<script lang="ts">
export default {name: 'ProjectSettingShare'}
</script>
<script lang="ts" setup>
import {ref, computed, watchEffect} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@vueuse/core'
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'
import LinkSharing from '@/components/sharing/linkSharing.vue'
import userTeam from '@/components/sharing/userTeam.vue'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
const {t} = useI18n({useScope: 'global'})
const project = ref<IProject>()
const title = computed(() => project.value?.title
? t('project.share.title', {project: project.value.title})
: '',
)
useTitle(title)
const configStore = useConfigStore()
const linkSharingEnabled = computed(() => configStore.linkSharingEnabled)
const userIsAdmin = computed(() => project?.value?.maxRight === RIGHTS.ADMIN)
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 projectId = computed(() => route.params.projectId !== undefined
? parseInt(route.params.projectId as string)
: undefined,
)
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
</script>

View File

@ -0,0 +1,280 @@
<script lang="ts">
export default {name: 'ProjectSettingWebhooks'}
</script>
<script lang="ts" setup>
import {ref, computed, watchEffect} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@vueuse/core'
import ProjectService from '@/services/project'
import ProjectModel from '@/models/project'
import type {IProject} from '@/modelTypes/IProject'
import CreateEdit from '@/components/misc/create-edit.vue'
import {useBaseStore} from '@/stores/base'
import type {IWebhook} from '@/modelTypes/IWebhook'
import WebhookService from '@/services/webhook'
import {formatDateShort} from '@/helpers/time/formatDate'
import User from '@/components/misc/user.vue'
import WebhookModel from '@/models/webhook'
import BaseButton from '@/components/base/BaseButton.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {success} from '@/message'
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
const {t} = useI18n({useScope: 'global'})
const project = ref<IProject>()
useTitle(t('project.webhooks.title'))
const showNewForm = ref(false)
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
await loadWebhooks()
}
const route = useRoute()
const projectId = computed(() => route.params.projectId !== undefined
? parseInt(route.params.projectId as string)
: undefined,
)
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
const webhooks = ref<IWebhook[]>()
const webhookService = new WebhookService()
const availableEvents = ref<string[]>()
async function loadWebhooks() {
webhooks.value = await webhookService.getAll({projectId: project.value.id})
availableEvents.value = await webhookService.getAvailableEvents()
}
const showDeleteModal = ref(false)
const webhookIdToDelete = ref<number>()
async function deleteWebhook() {
await webhookService.delete({
id: webhookIdToDelete.value,
projectId: project.value.id,
})
showDeleteModal.value = false
success({message: t('project.webhooks.deleteSuccess')})
await loadWebhooks()
}
const newWebhook = ref(new WebhookModel())
const newWebhookEvents = ref({})
async function create() {
validateTargetUrl()
if (!webhookTargetUrlValid.value) {
return
}
const selectedEvents = getSelectedEventsArray()
newWebhook.value.events = selectedEvents
validateSelectedEvents()
if (!selectedEventsValid.value) {
return
}
newWebhook.value.projectId = project.value.id
const created = await webhookService.create(newWebhook.value)
webhooks.value.push(created)
newWebhook.value = new WebhookModel()
showNewForm.value = false
}
const webhookTargetUrlValid = ref(true)
function validateTargetUrl() {
webhookTargetUrlValid.value = isValidHttpUrl(newWebhook.value.targetUrl)
}
const selectedEventsValid = ref(true)
function getSelectedEventsArray() {
return Object.entries(newWebhookEvents.value)
.filter(([_, use]) => use)
.map(([event]) => event)
}
function validateSelectedEvents() {
const events = getSelectedEventsArray()
if (events.length === 0) {
selectedEventsValid.value = false
}
}
</script>
<template>
<CreateEdit
:title="$t('project.webhooks.title')"
:has-primary-action="false"
:wide="true"
>
<x-button
v-if="!(webhooks?.length === 0 || showNewForm)"
icon="plus"
class="mb-4"
@click="showNewForm = true"
>
{{ $t('project.webhooks.create') }}
</x-button>
<div
v-if="webhooks?.length === 0 || showNewForm"
class="p-4"
>
<div class="field">
<label
class="label"
for="targetUrl"
>
{{ $t('project.webhooks.targetUrl') }}
</label>
<div class="control">
<input
id="targetUrl"
v-model="newWebhook.targetUrl"
required
class="input"
:placeholder="$t('project.webhooks.targetUrl')"
@focusout="validateTargetUrl"
>
</div>
<p
v-if="!webhookTargetUrlValid"
class="help is-danger"
>
{{ $t('project.webhooks.targetUrlInvalid') }}
</p>
</div>
<div class="field">
<label
class="label"
for="secret"
>
{{ $t('project.webhooks.secret') }}
</label>
<div class="control">
<input
id="secret"
v-model="newWebhook.secret"
class="input"
>
</div>
<p class="help">
{{ $t('project.webhooks.secretHint') }}
<BaseButton href="https://vikunja.io/docs/webhooks/">
{{ $t('project.webhooks.secretDocs') }}
</BaseButton>
</p>
</div>
<div class="field">
<label
class="label"
for="secret"
>
{{ $t('project.webhooks.events') }}
</label>
<p class="help">
{{ $t('project.webhooks.eventsHint') }}
</p>
<div class="control">
<Fancycheckbox
v-for="event in availableEvents"
:key="event"
v-model="newWebhookEvents[event]"
class="available-events-check"
@update:modelValue="validateSelectedEvents"
>
{{ event }}
</Fancycheckbox>
</div>
<p
v-if="!selectedEventsValid"
class="help is-danger"
>
{{ $t('project.webhooks.mustSelectEvents') }}
</p>
</div>
<x-button
icon="plus"
@click="create"
>
{{ $t('project.webhooks.create') }}
</x-button>
</div>
<table
v-if="webhooks?.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
>
<thead>
<tr>
<th>{{ $t('project.webhooks.targetUrl') }}</th>
<th>{{ $t('project.webhooks.events') }}</th>
<th>{{ $t('misc.created') }}</th>
<th>{{ $t('misc.createdBy') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="w in webhooks"
:key="w.id"
>
<td>{{ w.targetUrl }}</td>
<td>{{ w.events.join(', ') }}</td>
<td>{{ formatDateShort(w.created) }}</td>
<td>
<User
:avatar-size="25"
:user="w.createdBy"
/>
</td>
<td class="actions">
<x-button
class="is-danger"
icon="trash-alt"
@click="() => {showDeleteModal = true;webhookIdToDelete = w.id}"
/>
</td>
</tr>
</tbody>
</table>
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteWebhook()"
>
<template #header>
<span>{{ $t('project.webhooks.delete') }}</span>
</template>
<template #text>
<p>{{ $t('project.webhooks.deleteText') }}</p>
</template>
</modal>
</CreateEdit>
</template>
<style lang="scss" scoped>
.available-events-check {
margin-right: .5rem;
width: 12.5rem;
}
</style>