Compare commits
32 Commits
3cbafdaf1d
...
22b8873a40
Author | SHA1 | Date |
---|---|---|
renovate | 22b8873a40 | |
kolaente | ecbefdb921 | |
kolaente | d8ca1a2de1 | |
kolaente | 2d084c091e | |
kolaente | 5a84d37fca | |
kolaente | fd520dab0a | |
kolaente | 144a6e4140 | |
kolaente | a7aa74227a | |
kolaente | d2adbc53c6 | |
kolaente | 422e4371f8 | |
kolaente | 6e5b31f1e0 | |
kolaente | 5756da412b | |
kolaente | 4e05b8e97c | |
kolaente | 5177f516c4 | |
kolaente | 637c8f6ba5 | |
kolaente | 1460d212ee | |
kolaente | e9de7d8a24 | |
kolaente | ce1d7778c7 | |
kolaente | 9a16f6f817 | |
kolaente | 7d755fcb89 | |
kolaente | 77e95642a9 | |
kolaente | a5d02380a3 | |
kolaente | 3519b8b2fe | |
kolaente | cb648e5ad8 | |
kolaente | 75f830457b | |
kolaente | 6e2b540394 | |
kolaente | bf3c8ac9da | |
kolaente | 3e7225ebee | |
kolaente | 9eb19e0362 | |
kolaente | 73bf119409 | |
kolaente | 500b761fe6 | |
kolaente | 0bc9a670d7 |
|
@ -104,3 +104,7 @@ issues:
|
|||
text: "parameter 'tx' seems to be unused, consider removing or renaming it as"
|
||||
linters:
|
||||
- revive
|
||||
- path: pkg/models/typesense.go
|
||||
text: 'structtag: struct field Position repeats json tag "position" also at'
|
||||
linters:
|
||||
- govet
|
||||
|
|
|
@ -18,6 +18,7 @@ To fully build Vikunja from source files, you need to build the api and frontend
|
|||
|
||||
1. Make sure you have git installed
|
||||
2. Clone the repo with `git clone https://code.vikunja.io/vikunja` and switch into the directory.
|
||||
3. Check out the version you want to build with `git checkout VERSION` - replace `VERSION` with the version want to use. If you don't do this, you'll build the [latest unstable build]({{< ref "versions.md">}}), which might contain bugs.
|
||||
|
||||
## Frontend
|
||||
|
||||
|
|
|
@ -225,10 +225,13 @@ go install github.com/magefile/mage
|
|||
```
|
||||
mkdir /mnt/GO/code.vikunja.io
|
||||
cd /mnt/GO/code.vikunja.io
|
||||
git clone https://code.vikunja.io/api
|
||||
cd /mnt/GO/code.vikunja.io/api
|
||||
git clone https://code.vikunja.io/vikunja
|
||||
cd vikunja
|
||||
```
|
||||
|
||||
**Note:** Ceck out the version you want to build with `git checkout VERSION` - replace `VERSION` with the version want to use.
|
||||
If you don't do this, you'll build the [latest unstable build]({{< ref "versions.md">}}), which might contain bugs.
|
||||
|
||||
### Compile binaries
|
||||
|
||||
```
|
||||
|
|
|
@ -13,6 +13,7 @@ import {BucketFactory} from '../../factories/bucket'
|
|||
import {TaskAttachmentFactory} from '../../factories/task_attachments'
|
||||
import {TaskReminderFactory} from '../../factories/task_reminders'
|
||||
import {createDefaultViews} from "../project/prepareProjects";
|
||||
import { TaskBucketFactory } from '../../factories/task_buckets'
|
||||
|
||||
function addLabelToTaskAndVerify(labelTitle: string) {
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
|
@ -48,7 +49,7 @@ function uploadAttachmentAndVerify(taskId: number) {
|
|||
describe('Task', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let projects
|
||||
let projects: {}[]
|
||||
let buckets
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -470,6 +471,10 @@ describe('Task', () => {
|
|||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
TaskBucketFactory.create(1, {
|
||||
task_id: tasks[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}/4`)
|
||||
|
||||
|
|
|
@ -58,8 +58,8 @@
|
|||
"@infectoone/vue-ganttastic": "2.3.2",
|
||||
"@intlify/unplugin-vue-i18n": "3.0.1",
|
||||
"@kyvg/vue3-notification": "3.2.1",
|
||||
"@sentry/tracing": "7.110.0",
|
||||
"@sentry/vue": "7.110.0",
|
||||
"@sentry/tracing": "7.110.1",
|
||||
"@sentry/vue": "7.110.1",
|
||||
"@tiptap/core": "2.3.0",
|
||||
"@tiptap/extension-blockquote": "2.3.0",
|
||||
"@tiptap/extension-bold": "2.3.0",
|
||||
|
|
|
@ -38,11 +38,11 @@ dependencies:
|
|||
specifier: 3.2.1
|
||||
version: 3.2.1(vue@3.4.21)
|
||||
'@sentry/tracing':
|
||||
specifier: 7.110.0
|
||||
version: 7.110.0
|
||||
specifier: 7.110.1
|
||||
version: 7.110.1
|
||||
'@sentry/vue':
|
||||
specifier: 7.110.0
|
||||
version: 7.110.0(vue@3.4.21)
|
||||
specifier: 7.110.1
|
||||
version: 7.110.1(vue@3.4.21)
|
||||
'@tiptap/core':
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0(@tiptap/pm@2.3.0)
|
||||
|
@ -3057,45 +3057,45 @@ packages:
|
|||
resolution: {integrity: sha512-hw437iINopmQuxWPSUEvqE56NCPsiU8N4AYtfHmJFckclktzK9YQJieD3XkDCDH4OjL+C7zgPUh73R/nrcHrqw==}
|
||||
dev: true
|
||||
|
||||
/@sentry-internal/feedback@7.110.0:
|
||||
resolution: {integrity: sha512-hrfWa3WkSOiBO5Srcr1j4kuGOlbsQic+REpLOofllVIs56DOo9+Aj9svxT+dcvZERv/nlFSV/E0BfGy9g08IEg==}
|
||||
/@sentry-internal/feedback@7.110.1:
|
||||
resolution: {integrity: sha512-0aR3wuEW+SZKOVNamuy0pTQyPmqDjWPPLrB2GAXGT3ZjrVxjEzzVPqk6DVBYxSV2MuJaD507SZnvfoSPNgoBmw==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
'@sentry/core': 7.110.0
|
||||
'@sentry/types': 7.110.0
|
||||
'@sentry/utils': 7.110.0
|
||||
'@sentry/core': 7.110.1
|
||||
'@sentry/types': 7.110.1
|
||||
'@sentry/utils': 7.110.1
|
||||
dev: false
|
||||
|
||||
/@sentry-internal/replay-canvas@7.110.0:
|
||||
resolution: {integrity: sha512-SNa+AfyfX+vc6Xw0pIfDsa5Qnc9cpexU6M2D19gadtVhmep7qoFBuhBVZrSv6BtdCxvrb5EyYsHYGfjQdIDcvg==}
|
||||
/@sentry-internal/replay-canvas@7.110.1:
|
||||
resolution: {integrity: sha512-zdcCmWFXM4DHOau/BCZVb6jf9zozdbAiJ1MzQ6azuZEuysOl00YfktoWZBbZjjjpWT6025s+wrmFz54t0O+enw==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
'@sentry/core': 7.110.0
|
||||
'@sentry/replay': 7.110.0
|
||||
'@sentry/types': 7.110.0
|
||||
'@sentry/utils': 7.110.0
|
||||
'@sentry/core': 7.110.1
|
||||
'@sentry/replay': 7.110.1
|
||||
'@sentry/types': 7.110.1
|
||||
'@sentry/utils': 7.110.1
|
||||
dev: false
|
||||
|
||||
/@sentry-internal/tracing@7.110.0:
|
||||
resolution: {integrity: sha512-IIHHa9e/mE7uOMJfNELI8adyoELxOy6u6TNCn5t6fphmq84w8FTc9adXkG/FY2AQpglkIvlILojfMROFB2aaAQ==}
|
||||
/@sentry-internal/tracing@7.110.1:
|
||||
resolution: {integrity: sha512-4kTd6EM0OP1SVWl2yLn3KIwlCpld1lyhNDeR8G1aKLm1PN+kVsR6YB/jy9KPPp4Q3lN3W9EkTSES3qhP4jVffQ==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
'@sentry/core': 7.110.0
|
||||
'@sentry/types': 7.110.0
|
||||
'@sentry/utils': 7.110.0
|
||||
'@sentry/core': 7.110.1
|
||||
'@sentry/types': 7.110.1
|
||||
'@sentry/utils': 7.110.1
|
||||
dev: false
|
||||
|
||||
/@sentry/browser@7.110.0:
|
||||
resolution: {integrity: sha512-gIxedVm6ZgkjQfgCDgLWJgAsolq6OxV8hQ2j1+RaDL2RngvelFo/vlX5f2sD6EbjVp77Cri8u5GkMJF+v4p84g==}
|
||||
/@sentry/browser@7.110.1:
|
||||
resolution: {integrity: sha512-H3TZlbdsgxuoVxhotMtBDemvAofx3UPNcS+UjQ40Bd+hKX01IIbEN3i+9RQ0jmcbU6xjf+yhjwp+Ejpm4FmYMw==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
'@sentry-internal/feedback': 7.110.0
|
||||
'@sentry-internal/replay-canvas': 7.110.0
|
||||
'@sentry-internal/tracing': 7.110.0
|
||||
'@sentry/core': 7.110.0
|
||||
'@sentry/replay': 7.110.0
|
||||
'@sentry/types': 7.110.0
|
||||
'@sentry/utils': 7.110.0
|
||||
'@sentry-internal/feedback': 7.110.1
|
||||
'@sentry-internal/replay-canvas': 7.110.1
|
||||
'@sentry-internal/tracing': 7.110.1
|
||||
'@sentry/core': 7.110.1
|
||||
'@sentry/replay': 7.110.1
|
||||
'@sentry/types': 7.110.1
|
||||
'@sentry/utils': 7.110.1
|
||||
dev: false
|
||||
|
||||
/@sentry/cli@2.19.1:
|
||||
|
@ -3114,53 +3114,53 @@ packages:
|
|||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@sentry/core@7.110.0:
|
||||
resolution: {integrity: sha512-g4suCQO94mZsKVaAbyD1zLFC5YSuBQCIPHXx9fdgtfoPib7BWjWWePkllkrvsKAv4u8Oq05RfnKOhOMRHpOKqg==}
|
||||
/@sentry/core@7.110.1:
|
||||
resolution: {integrity: sha512-yC1yeUFQlmHj9u/KxKmwOMVanBmgfX+4MZnZU31QPqN95adyZTwpaYFZl4fH5kDVnz7wXJI0qRP8SxuMePtqhw==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
'@sentry/types': 7.110.0
|
||||
'@sentry/utils': 7.110.0
|
||||
'@sentry/types': 7.110.1
|
||||
'@sentry/utils': 7.110.1
|
||||
dev: false
|
||||
|
||||
/@sentry/replay@7.110.0:
|
||||
resolution: {integrity: sha512-EEpGPf3iBJjWejvoxKLVMnLtLNwPTUxHJV1oxUkbcSi3B/tG5hW7LArYDjAcvkfa4VmA8JLCwj2vYU5MQ8tj6g==}
|
||||
/@sentry/replay@7.110.1:
|
||||
resolution: {integrity: sha512-R49fGOuKYsJ97EujPTzMjs3ZSuSkLTFFQmVBbsu/o6beRp4kK9l8H7r2BfLEcWJOXdWO5EU4KpRWgIxHaDK2aw==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
'@sentry-internal/tracing': 7.110.0
|
||||
'@sentry/core': 7.110.0
|
||||
'@sentry/types': 7.110.0
|
||||
'@sentry/utils': 7.110.0
|
||||
'@sentry-internal/tracing': 7.110.1
|
||||
'@sentry/core': 7.110.1
|
||||
'@sentry/types': 7.110.1
|
||||
'@sentry/utils': 7.110.1
|
||||
dev: false
|
||||
|
||||
/@sentry/tracing@7.110.0:
|
||||
resolution: {integrity: sha512-pAydcCqzyzn2Uv9qmuDX5saHbXp4eMMsBW2C/oSkVdKQQSdA7JeG27d82Jz3cMVrfjv105lShP5qS2YjhBTkow==}
|
||||
/@sentry/tracing@7.110.1:
|
||||
resolution: {integrity: sha512-y9tFHCV/8PeuV9TBOyMJ9w1vVOhkzoHy9d78eO5N3Q1lC+wImJSEBxb6aPcZ+RkuFBKSMm7fZTF3OFA32dHHLA==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
'@sentry-internal/tracing': 7.110.0
|
||||
'@sentry-internal/tracing': 7.110.1
|
||||
dev: false
|
||||
|
||||
/@sentry/types@7.110.0:
|
||||
resolution: {integrity: sha512-DqYBLyE8thC5P5MuPn+sj8tL60nCd/f5cerFFPcudn5nJ4Zs1eI6lKlwwyHYTEu5c4KFjCB0qql6kXfwAHmTyA==}
|
||||
/@sentry/types@7.110.1:
|
||||
resolution: {integrity: sha512-sZxOpM5gfyxvJeWVvNpHnxERTnlqcozjqNcIv29SZ6wonlkekmxDyJ3uCuPv85VO54WLyA4uzskPKnNFHacI8A==}
|
||||
engines: {node: '>=8'}
|
||||
dev: false
|
||||
|
||||
/@sentry/utils@7.110.0:
|
||||
resolution: {integrity: sha512-VBsdLLN+5tf73fhf/Cm7JIsUJ6y9DkJj8h4I6Mxx0rszrvOyH6S5px40K+V4jdLBzMEvVinC7q2Cbf1YM18BSw==}
|
||||
/@sentry/utils@7.110.1:
|
||||
resolution: {integrity: sha512-eibLo2m1a7sHkOHxYYmRujr3D7ek2l9sv26F1SLoQBVDF7Afw5AKyzPmtA1D+4M9P/ux1okj7cGj3SaBrVpxXA==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
'@sentry/types': 7.110.0
|
||||
'@sentry/types': 7.110.1
|
||||
dev: false
|
||||
|
||||
/@sentry/vue@7.110.0(vue@3.4.21):
|
||||
resolution: {integrity: sha512-N8qAAPNJMV9fRMfvbRIWgFrn+wNH6ABGdc7fbFg1y3y0rOw58YMMg0+WdHMGEeWhH7N2/cCJGUHdz4egqaM3gQ==}
|
||||
/@sentry/vue@7.110.1(vue@3.4.21):
|
||||
resolution: {integrity: sha512-+dS5gfr360wiWgdRMPgJHl+mF/44U1/H5CYwTL/a1X7psRY4NEQSo7oltAGeZc723hmlJubxmwJYU4xBWgaasw==}
|
||||
engines: {node: '>=8'}
|
||||
peerDependencies:
|
||||
vue: 2.x || 3.x
|
||||
dependencies:
|
||||
'@sentry/browser': 7.110.0
|
||||
'@sentry/core': 7.110.0
|
||||
'@sentry/types': 7.110.0
|
||||
'@sentry/utils': 7.110.0
|
||||
'@sentry/browser': 7.110.1
|
||||
'@sentry/core': 7.110.1
|
||||
'@sentry/types': 7.110.1
|
||||
'@sentry/utils': 7.110.1
|
||||
vue: 3.4.21(typescript@5.4.5)
|
||||
dev: false
|
||||
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 218 KiB |
|
@ -162,7 +162,7 @@ projectStore.loadAllProjects()
|
|||
.app-content {
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
padding: 1.5rem 0.5rem 1rem;
|
||||
padding: 1.5rem 0.5rem 0;
|
||||
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
|
||||
transition: margin-left $transition-duration;
|
||||
|
||||
|
@ -172,7 +172,7 @@ projectStore.loadAllProjects()
|
|||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
|
||||
padding: $navbar-height + 1.5rem 1.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
&.is-menu-enabled {
|
||||
|
|
|
@ -338,10 +338,12 @@ const editor = useEditor({
|
|||
HardBreak.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Shift-Enter': () => this.editor.commands.setHardBreak(),
|
||||
'Mod-Enter': () => {
|
||||
if (contentHasChanged.value) {
|
||||
bubbleSave()
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
@ -88,7 +88,7 @@ const currentProject = computed<IProject>(() => {
|
|||
})
|
||||
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
|
||||
|
||||
const views = computed(() => currentProject.value?.views)
|
||||
const views = computed(() => projectStore.projects[projectId]?.views)
|
||||
|
||||
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||
// This resulted in loading and setting the project multiple times, even when navigating away from it.
|
||||
|
@ -161,6 +161,7 @@ function getViewTitle(view: IProjectView) {
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
justify-content: center;
|
||||
|
|
|
@ -30,45 +30,27 @@ import {computed, ref, watch} from 'vue'
|
|||
|
||||
import Filters from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
import {type TaskFilterParams} from '@/services/taskCollection'
|
||||
|
||||
const modelValue = defineModel<TaskFilterParams>({})
|
||||
const props = defineProps(['modelValue'])
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const value = ref<TaskFilterParams>({})
|
||||
const filter = useRouteQuery('filter')
|
||||
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
() => props.modelValue,
|
||||
(modelValue: TaskFilterParams) => {
|
||||
value.value = modelValue
|
||||
if (value.value.filter !== '' && value.value.filter !== getDefaultTaskFilterParams().filter) {
|
||||
filter.value = value.value.filter
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filter.value,
|
||||
val => {
|
||||
if (modelValue.value?.filter === val || typeof val === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
modelValue.value.filter = val
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function emitChanges(newValue: TaskFilterParams) {
|
||||
filter.value = newValue.filter
|
||||
if (modelValue.value?.filter === newValue.filter && modelValue.value?.s === newValue.s) {
|
||||
return
|
||||
}
|
||||
|
||||
modelValue.value.filter = newValue.filter
|
||||
modelValue.value.s = newValue.s
|
||||
emit('update:modelValue', {
|
||||
...value.value,
|
||||
filter: newValue.filter,
|
||||
s: newValue.s,
|
||||
})
|
||||
}
|
||||
|
||||
const hasFilters = computed(() => {
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
role="search"
|
||||
>
|
||||
<FilterInput
|
||||
v-model="params.filter"
|
||||
v-model="filterQuery"
|
||||
:project-id="projectId"
|
||||
@blur="change()"
|
||||
/>
|
||||
|
@ -28,7 +28,7 @@
|
|||
<x-button
|
||||
variant="secondary"
|
||||
class="mr-2"
|
||||
:disabled="params.filter === ''"
|
||||
:disabled="filterQuery === ''"
|
||||
@click.prevent.stop="clearFiltersAndEmit"
|
||||
>
|
||||
{{ $t('filters.clear') }}
|
||||
|
@ -87,6 +87,16 @@ const params = ref<TaskFilterParams>({
|
|||
s: '',
|
||||
})
|
||||
|
||||
const filterQuery = ref('')
|
||||
watch(
|
||||
() => [params.value.filter, params.value.s],
|
||||
() => {
|
||||
const filter = params.value.filter || ''
|
||||
const s = params.value.s || ''
|
||||
filterQuery.value = filter || s
|
||||
},
|
||||
)
|
||||
|
||||
// Using watchDebounced to prevent the filter re-triggering itself.
|
||||
watch(
|
||||
() => modelValue,
|
||||
|
@ -107,7 +117,7 @@ const projectStore = useProjectStore()
|
|||
|
||||
function change() {
|
||||
const filter = transformFilterStringForApi(
|
||||
params.value.filter,
|
||||
filterQuery.value,
|
||||
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
|
||||
projectTitle => {
|
||||
const found = projectStore.findProjectByExactname(projectTitle)
|
||||
|
@ -142,7 +152,7 @@ function changeAndEmitButton() {
|
|||
}
|
||||
|
||||
function clearFiltersAndEmit() {
|
||||
params.value.filter = ''
|
||||
filterQuery.value = ''
|
||||
changeAndEmitButton()
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -773,11 +773,6 @@ $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;
|
||||
|
@ -785,6 +780,10 @@ $filter-container-height: '1rem - #{$switch-view-height}';
|
|||
margin: 0 -1.5rem;
|
||||
padding: 0 1.5rem;
|
||||
|
||||
&:focus, .bucket .tasks:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
|
||||
scroll-snap-type: x mandatory;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
|
||||
import TaskCollectionService, {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
|
||||
|
@ -66,7 +66,6 @@ export function useTaskList(
|
|||
|
||||
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
|
||||
|
||||
const search = ref('')
|
||||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
|
||||
const sortBy = ref({ ...sortByDefault })
|
||||
|
@ -74,10 +73,6 @@ export function useTaskList(
|
|||
const allParams = computed(() => {
|
||||
const loadParams = {...params.value}
|
||||
|
||||
if (search.value !== '') {
|
||||
loadParams.s = search.value
|
||||
}
|
||||
|
||||
return formatSortOrder(sortBy.value, loadParams)
|
||||
})
|
||||
|
||||
|
@ -122,16 +117,38 @@ export function useTaskList(
|
|||
|
||||
const route = useRoute()
|
||||
watch(() => route.query, (query) => {
|
||||
const { page: pageQueryValue, search: searchQuery } = query
|
||||
if (searchQuery !== undefined) {
|
||||
search.value = searchQuery as string
|
||||
const {
|
||||
page: pageQueryValue,
|
||||
s,
|
||||
filter,
|
||||
} = query
|
||||
if (s !== undefined) {
|
||||
params.value.s = s as string
|
||||
}
|
||||
if (pageQueryValue !== undefined) {
|
||||
page.value = Number(pageQueryValue)
|
||||
}
|
||||
|
||||
if (filter !== undefined) {
|
||||
params.value.filter = filter
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const router = useRouter()
|
||||
watch(
|
||||
() => [page.value, params.value.filter, params.value.s],
|
||||
() => {
|
||||
router.replace({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: {
|
||||
page: page.value,
|
||||
filter: params.value.filter || undefined,
|
||||
s: params.value.s || undefined,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Only listen for query path changes
|
||||
watch(() => JSON.stringify(getAllTasksParams.value), (newParams, oldParams) => {
|
||||
|
@ -148,7 +165,6 @@ export function useTaskList(
|
|||
totalPages,
|
||||
currentPage: page,
|
||||
loadTasks,
|
||||
searchTerm: search,
|
||||
params,
|
||||
sortByParam: sortBy,
|
||||
}
|
||||
|
|
|
@ -19,6 +19,13 @@ export function secondsToPeriod(seconds: number): { unit: PeriodUnit, amount: nu
|
|||
}
|
||||
}
|
||||
|
||||
if (seconds % SECONDS_A_MINUTE === 0) {
|
||||
return {
|
||||
unit: 'minutes',
|
||||
amount: seconds / SECONDS_A_MINUTE,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
unit: 'hours',
|
||||
amount: seconds / SECONDS_A_HOUR,
|
||||
|
|
|
@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||
import type { RouteLocation } from 'vue-router'
|
||||
import {saveLastVisited} from '@/helpers/saveLastVisited'
|
||||
|
||||
import {saveProjectView, getProjectViewId} from '@/helpers/projectView'
|
||||
import {getProjectViewId} from '@/helpers/projectView'
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
|
||||
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
|
||||
|
@ -366,7 +366,6 @@ const router = createRouter({
|
|||
path: '/projects/:projectId/:viewId',
|
||||
name: 'project.view',
|
||||
component: ProjectView,
|
||||
beforeEnter: (to) => saveProjectView(parseInt(to.params.projectId as string), parseInt(to.params.viewId as string)),
|
||||
props: route => ({
|
||||
projectId: parseInt(route.params.projectId as string),
|
||||
viewId: route.params.viewId ? parseInt(route.params.viewId as string): undefined,
|
||||
|
|
|
@ -207,13 +207,17 @@ export const useProjectStore = defineStore('project', () => {
|
|||
}
|
||||
|
||||
function getAncestors(project: IProject): IProject[] {
|
||||
if (typeof project === 'undefined') {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!project?.parentProjectId) {
|
||||
return [project]
|
||||
}
|
||||
|
||||
const parentProject = projects.value[project.parentProjectId]
|
||||
return [
|
||||
...getAncestors(parentProject),
|
||||
...(parentProject ? getAncestors(parentProject) : []),
|
||||
project,
|
||||
]
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import {computed, watch} from 'vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {saveProjectView} from '@/helpers/projectView'
|
||||
|
||||
import ProjectList from '@/components/project/views/ProjectList.vue'
|
||||
import ProjectGantt from '@/components/project/views/ProjectGantt.vue'
|
||||
|
@ -53,6 +54,13 @@ watch(
|
|||
redirectToFirstViewIfNecessary,
|
||||
)
|
||||
|
||||
// using a watcher instead of beforeEnter because beforeEnter is not called when only the viewId changes
|
||||
watch(
|
||||
() => [projectId, viewId],
|
||||
() => saveProjectView(projectId, viewId),
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
|
|
|
@ -32,9 +32,9 @@ The to-do app to organize your life.
|
|||
Also one of the two wild South American camelids which live in the high
|
||||
alpine areas of the Andes and a relative of the llama.
|
||||
|
||||
Vikunja is a self-hosted To-Do list application with a web app and mobile apps for all platforms. It is licensed under the GPLv3.
|
||||
Vikunja is a self-hosted To-Do list application with a web app and mobile apps for all platforms. It is licensed under the AGPLv3.
|
||||
|
||||
Find more info at vikunja.io.`,
|
||||
Find out more at vikunja.io.`,
|
||||
PreRun: webCmd.PreRun,
|
||||
Run: webCmd.Run,
|
||||
}
|
||||
|
|
|
@ -82,7 +82,6 @@ func init() {
|
|||
|
||||
if config.DatabaseType.GetString() == "sqlite" {
|
||||
_, err = tx.Exec(`
|
||||
|
||||
create table buckets_dg_tmp
|
||||
(
|
||||
id INTEGER not null
|
||||
|
@ -107,7 +106,7 @@ drop table buckets;
|
|||
alter table buckets_dg_tmp
|
||||
rename to buckets;
|
||||
|
||||
create unique index UQE_buckets_id
|
||||
create unique index if not exists UQE_buckets_id
|
||||
on buckets (id);
|
||||
`)
|
||||
return err
|
||||
|
|
|
@ -155,6 +155,21 @@ func exportProjectsAndTasks(s *xorm.Session, u *user.User, wr *zip.Writer) (task
|
|||
projectIDs = append(projectIDs, p.ID)
|
||||
}
|
||||
|
||||
views := map[int64]*ProjectView{}
|
||||
err = s.In("project_id", projectIDs).Find(&views)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
viewIDs := []int64{}
|
||||
for _, v := range views {
|
||||
if projectsMap[v.ProjectID].Views == nil {
|
||||
projectsMap[v.ProjectID].Views = []*ProjectView{}
|
||||
}
|
||||
projectsMap[v.ProjectID].Views = append(projectsMap[v.ProjectID].Views, v)
|
||||
viewIDs = append(viewIDs, v.ID)
|
||||
}
|
||||
|
||||
tasks, _, _, err := getTasksForProjects(s, rawProjects, u, &taskSearchOptions{
|
||||
page: 0,
|
||||
perPage: -1,
|
||||
|
@ -194,17 +209,75 @@ func exportProjectsAndTasks(s *xorm.Session, u *user.User, wr *zip.Writer) (task
|
|||
}
|
||||
|
||||
buckets := []*Bucket{}
|
||||
err = s.In("project_id", projectIDs).Find(&buckets)
|
||||
err = s.In("project_view_id", viewIDs).Find(&buckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
bucketIDs := []int64{}
|
||||
for _, b := range buckets {
|
||||
if _, exists := projectsMap[b.ProjectID]; !exists {
|
||||
log.Debugf("[User Data Export] Project %d does not exist for bucket %d, omitting", b.ProjectID, b.ID)
|
||||
view, exists := views[b.ProjectViewID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project view %d does not exist for bucket %d, omitting", b.ProjectViewID, b.ID)
|
||||
continue
|
||||
}
|
||||
projectsMap[b.ProjectID].Buckets = append(projectsMap[b.ProjectID].Buckets, b)
|
||||
_, exists = projectsMap[view.ProjectID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project %d does not exist for bucket %d, omitting", view.ProjectID, b.ID)
|
||||
continue
|
||||
}
|
||||
projectsMap[view.ProjectID].Buckets = append(projectsMap[view.ProjectID].Buckets, b)
|
||||
bucketIDs = append(bucketIDs, b.ID)
|
||||
}
|
||||
|
||||
taskBuckets := []*TaskBucket{}
|
||||
err = s.In("bucket_id", bucketIDs).Find(&taskBuckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, tb := range taskBuckets {
|
||||
view, exists := views[tb.ProjectViewID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project view %d does not exist, omitting", tb.ProjectViewID)
|
||||
continue
|
||||
}
|
||||
_, exists = projectsMap[view.ProjectID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project %d does not exist, omitting", view.ProjectID)
|
||||
continue
|
||||
}
|
||||
|
||||
if projectsMap[view.ProjectID].TaskBuckets == nil {
|
||||
projectsMap[view.ProjectID].TaskBuckets = []*TaskBucket{}
|
||||
}
|
||||
|
||||
projectsMap[view.ProjectID].TaskBuckets = append(projectsMap[view.ProjectID].TaskBuckets, tb)
|
||||
}
|
||||
|
||||
taskPositions := []*TaskPosition{}
|
||||
err = s.In("project_view_id", viewIDs).Find(&taskPositions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, p := range taskPositions {
|
||||
view, exists := views[p.ProjectViewID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project view %d does not exist, omitting", p.ProjectViewID)
|
||||
continue
|
||||
}
|
||||
_, exists = projectsMap[view.ProjectID]
|
||||
if !exists {
|
||||
log.Debugf("[User Data Export] Project %d does not exist, omitting", view.ProjectID)
|
||||
continue
|
||||
}
|
||||
|
||||
if projectsMap[view.ProjectID].Positions == nil {
|
||||
projectsMap[view.ProjectID].Positions = []*TaskPosition{}
|
||||
}
|
||||
|
||||
projectsMap[view.ProjectID].Positions = append(projectsMap[view.ProjectID].Positions, p)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(projects)
|
||||
|
|
|
@ -529,7 +529,18 @@ func (l *AddTaskToTypesense) Handle(msg *message.Message) (err error) {
|
|||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
ttask, err := getTypesenseTaskForTask(s, event.Task, nil)
|
||||
|
||||
positionsMap, err := getPositionsForTask(s, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bucketsMap, err := getBucketsForTask(s, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ttask, err := getTypesenseTaskForTask(s, event.Task, nil, positionsMap, bucketsMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -540,6 +551,36 @@ func (l *AddTaskToTypesense) Handle(msg *message.Message) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func getPositionsForTask(s *xorm.Session, event *TaskCreatedEvent) (positionsMap map[int64][]*TaskPositionWithView, err error) {
|
||||
positions := []*TaskPositionWithView{}
|
||||
err = s.
|
||||
Table("project_views").
|
||||
Where("project_views.project_id = ?", event.Task.ProjectID).
|
||||
Join("LEFT", "task_positions", "project_views.id = task_positions.project_view_id AND task_positions.task_id = ?", event.Task.ID).
|
||||
Find(&positions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
positionsMap = make(map[int64][]*TaskPositionWithView, 1)
|
||||
positionsMap[event.Task.ID] = positions
|
||||
return
|
||||
}
|
||||
|
||||
func getBucketsForTask(s *xorm.Session, event *TaskCreatedEvent) (bucketsMap map[int64][]*TaskBucket, err error) {
|
||||
buckets := []*TaskBucket{}
|
||||
err = s.
|
||||
Where("task_id = ?", event.Task.ID).
|
||||
Find(&buckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
bucketsMap = make(map[int64][]*TaskBucket, 1)
|
||||
bucketsMap[event.Task.ID] = buckets
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateTaskInTypesense represents a listener
|
||||
type UpdateTaskInTypesense struct {
|
||||
}
|
||||
|
|
|
@ -93,8 +93,10 @@ type ProjectWithTasksAndBuckets struct {
|
|||
// An array of tasks which belong to the project.
|
||||
Tasks []*TaskWithComments `xorm:"-" json:"tasks"`
|
||||
// Only used for migration.
|
||||
Buckets []*Bucket `xorm:"-" json:"buckets"`
|
||||
BackgroundFileID int64 `xorm:"null" json:"background_file_id"`
|
||||
Buckets []*Bucket `xorm:"-" json:"buckets"`
|
||||
TaskBuckets []*TaskBucket `xorm:"-" json:"task_buckets"`
|
||||
Positions []*TaskPosition `xorm:"-" json:"positions"`
|
||||
BackgroundFileID int64 `xorm:"null" json:"background_file_id"`
|
||||
}
|
||||
|
||||
// TableName returns a better name for the projects table
|
||||
|
@ -110,15 +112,43 @@ type ProjectBackgroundType struct {
|
|||
// ProjectBackgroundUpload represents the project upload background type
|
||||
const ProjectBackgroundUpload string = "upload"
|
||||
|
||||
const FavoritesPseudoProjectID = -1
|
||||
|
||||
// FavoritesPseudoProject holds all tasks marked as favorites
|
||||
var FavoritesPseudoProject = Project{
|
||||
ID: -1,
|
||||
ID: FavoritesPseudoProjectID,
|
||||
Title: "Favorites",
|
||||
Description: "This project has all tasks marked as favorites.",
|
||||
IsFavorite: true,
|
||||
Position: -1,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
|
||||
Views: []*ProjectView{
|
||||
{
|
||||
ID: -1,
|
||||
ProjectID: FavoritesPseudoProjectID,
|
||||
Title: "List",
|
||||
ViewKind: ProjectViewKindList,
|
||||
Position: 100,
|
||||
Filter: "done = false",
|
||||
},
|
||||
{
|
||||
ID: -2,
|
||||
ProjectID: FavoritesPseudoProjectID,
|
||||
Title: "Gantt",
|
||||
ViewKind: ProjectViewKindGantt,
|
||||
Position: 200,
|
||||
},
|
||||
{
|
||||
ID: -3,
|
||||
ProjectID: FavoritesPseudoProjectID,
|
||||
Title: "Table",
|
||||
ViewKind: ProjectViewKindTable,
|
||||
Position: 300,
|
||||
},
|
||||
},
|
||||
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
}
|
||||
|
||||
// ReadAll gets all projects a user has access to
|
||||
|
@ -146,6 +176,9 @@ func (p *Project) ReadAll(s *xorm.Session, a web.Auth, search string, page int,
|
|||
}
|
||||
projects := []*Project{project}
|
||||
err = addProjectDetails(s, projects, a)
|
||||
if err == nil && len(projects) > 0 {
|
||||
projects[0].ParentProjectID = 0
|
||||
}
|
||||
return projects, 0, 0, err
|
||||
}
|
||||
|
||||
|
@ -207,6 +240,7 @@ func (p *Project) ReadAll(s *xorm.Session, a web.Auth, search string, page int,
|
|||
func (p *Project) ReadOne(s *xorm.Session, a web.Auth) (err error) {
|
||||
|
||||
if p.ID == FavoritesPseudoProject.ID {
|
||||
p.Views = FavoritesPseudoProject.Views
|
||||
// Already "built" the project in CanRead
|
||||
return nil
|
||||
}
|
||||
|
@ -226,6 +260,11 @@ func (p *Project) ReadOne(s *xorm.Session, a web.Auth) (err error) {
|
|||
p.OwnerID = sf.OwnerID
|
||||
}
|
||||
|
||||
_, isShareAuth := a.(*LinkSharing)
|
||||
if isShareAuth {
|
||||
p.ParentProjectID = 0
|
||||
}
|
||||
|
||||
// Get project owner
|
||||
p.Owner, err = user.GetUserByID(s, p.OwnerID)
|
||||
if err != nil {
|
||||
|
@ -1073,6 +1112,46 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Delete related project entities
|
||||
views, err := getViewsForProject(s, p.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
viewIDs := []int64{}
|
||||
for _, v := range views {
|
||||
viewIDs = append(viewIDs, v.ID)
|
||||
}
|
||||
|
||||
_, err = s.In("project_view_id", viewIDs).Delete(&Bucket{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.In("id", viewIDs).Delete(&ProjectView{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = removeFromFavorite(s, p.ID, a, FavoriteKindProject)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.Where("project_id = ?", p.ID).Delete(&LinkSharing{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.Where("project_id = ?", p.ID).Delete(&ProjectUser{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.Where("project_id = ?", p.ID).Delete(&TeamProject{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the project
|
||||
_, err = s.ID(p.ID).Delete(&Project{})
|
||||
if err != nil {
|
||||
|
|
|
@ -159,6 +159,7 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) {
|
|||
|
||||
log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID)
|
||||
|
||||
err = pd.Project.ReadOne(s, doer)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -226,9 +227,11 @@ func duplicateViews(s *xorm.Session, pd *ProjectDuplicate, doer web.Auth, taskMa
|
|||
})
|
||||
}
|
||||
|
||||
_, err = s.Insert(&taskBuckets)
|
||||
if err != nil {
|
||||
return err
|
||||
if len(taskBuckets) > 0 {
|
||||
_, err = s.Insert(&taskBuckets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
oldTaskPositions := []*TaskPosition{}
|
||||
|
@ -246,7 +249,9 @@ func duplicateViews(s *xorm.Session, pd *ProjectDuplicate, doer web.Auth, taskMa
|
|||
})
|
||||
}
|
||||
|
||||
_, err = s.Insert(&taskPositions)
|
||||
if len(taskPositions) > 0 {
|
||||
_, err = s.Insert(&taskPositions)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -347,10 +347,22 @@ func (p *ProjectView) Update(s *xorm.Session, _ web.Auth) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func GetProjectViewByIDAndProject(s *xorm.Session, id, projectID int64) (view *ProjectView, err error) {
|
||||
func GetProjectViewByIDAndProject(s *xorm.Session, viewID, projectID int64) (view *ProjectView, err error) {
|
||||
if projectID == FavoritesPseudoProjectID && viewID < 0 {
|
||||
for _, v := range FavoritesPseudoProject.Views {
|
||||
if v.ID == viewID {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, &ErrProjectViewDoesNotExist{
|
||||
ProjectViewID: viewID,
|
||||
}
|
||||
}
|
||||
|
||||
view = &ProjectView{}
|
||||
exists, err := s.
|
||||
Where("id = ? AND project_id = ?", id, projectID).
|
||||
Where("id = ? AND project_id = ?", viewID, projectID).
|
||||
NoAutoCondition().
|
||||
Get(view)
|
||||
if err != nil {
|
||||
|
@ -359,7 +371,7 @@ func GetProjectViewByIDAndProject(s *xorm.Session, id, projectID int64) (view *P
|
|||
|
||||
if !exists {
|
||||
return nil, &ErrProjectViewDoesNotExist{
|
||||
ProjectViewID: id,
|
||||
ProjectViewID: viewID,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -100,6 +100,10 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie
|
|||
param.orderBy = getSortOrderFromString(tf.OrderBy[i])
|
||||
}
|
||||
|
||||
if s == taskPropertyPosition && projectView != nil && projectView.ID < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if s == taskPropertyPosition && projectView != nil {
|
||||
param.projectViewID = projectView.ID
|
||||
}
|
||||
|
@ -249,11 +253,20 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
|
|||
opts.perPage = perPage
|
||||
|
||||
if view != nil {
|
||||
opts.sortby = append(opts.sortby, &sortParam{
|
||||
projectViewID: view.ID,
|
||||
sortBy: taskPropertyPosition,
|
||||
orderBy: orderAscending,
|
||||
})
|
||||
var hasOrderByPosition bool
|
||||
for _, param := range opts.sortby {
|
||||
if param.sortBy == taskPropertyPosition {
|
||||
hasOrderByPosition = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOrderByPosition {
|
||||
opts.sortby = append(opts.sortby, &sortParam{
|
||||
projectViewID: view.ID,
|
||||
sortBy: taskPropertyPosition,
|
||||
orderBy: orderAscending,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
shareAuth, is := a.(*LinkSharing)
|
||||
|
|
|
@ -65,6 +65,14 @@ func (tc *TaskComment) TableName() string {
|
|||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /tasks/{taskID}/comments [put]
|
||||
func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) {
|
||||
|
||||
tc.Created = time.Time{}
|
||||
tc.Updated = time.Time{}
|
||||
|
||||
return tc.CreateWithTimestamps(s, a)
|
||||
}
|
||||
|
||||
func (tc *TaskComment) CreateWithTimestamps(s *xorm.Session, a web.Auth) (err error) {
|
||||
// Check if the task exists
|
||||
task, err := GetTaskSimple(s, &Task{ID: tc.TaskID})
|
||||
if err != nil {
|
||||
|
@ -77,9 +85,16 @@ func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) {
|
|||
}
|
||||
tc.AuthorID = tc.Author.ID
|
||||
|
||||
_, err = s.Insert(tc)
|
||||
if err != nil {
|
||||
return
|
||||
if !tc.Created.IsZero() && !tc.Updated.IsZero() {
|
||||
_, err = s.NoAutoTime().Insert(tc)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err = s.Insert(tc)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return events.Dispatch(&TaskCommentCreatedEvent{
|
||||
|
@ -255,7 +270,7 @@ func (tc *TaskComment) ReadAll(s *xorm.Session, auth web.Auth, search string, pa
|
|||
query := s.
|
||||
Where(builder.And(where...)).
|
||||
Join("LEFT", "users", "users.id = task_comments.author_id").
|
||||
OrderBy("task_comments.id asc")
|
||||
OrderBy("task_comments.created asc")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit, start)
|
||||
}
|
||||
|
|
|
@ -297,6 +297,7 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
|
|||
queryCount = queryCount.Join("LEFT", "task_buckets", "task_buckets.task_id = tasks.id")
|
||||
}
|
||||
totalCount, err = queryCount.
|
||||
Select("count(DISTINCT tasks.id)").
|
||||
Count(&Task{})
|
||||
return
|
||||
}
|
||||
|
@ -379,6 +380,10 @@ func convertParsedFilterToTypesense(rawFilters []*taskFilter) (filterBy string,
|
|||
f.field = "project_id"
|
||||
}
|
||||
|
||||
if f.field == "bucket_id" {
|
||||
f.field = "buckets"
|
||||
}
|
||||
|
||||
filter := f.field
|
||||
|
||||
switch f.comparator {
|
||||
|
@ -450,6 +455,7 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
|
|||
"(" + filter + ")",
|
||||
}
|
||||
|
||||
var projectViewIDForPosition int64
|
||||
var sortbyFields []string
|
||||
for i, param := range opts.sortby {
|
||||
// Validate the params
|
||||
|
@ -457,17 +463,19 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
|
|||
return nil, totalCount, err
|
||||
}
|
||||
|
||||
sortBy := param.sortBy
|
||||
|
||||
// Typesense does not allow sorting by ID, so we sort by created timestamp instead
|
||||
if param.sortBy == taskPropertyID {
|
||||
param.sortBy = taskPropertyCreated
|
||||
sortBy = taskPropertyCreated
|
||||
}
|
||||
|
||||
if param.sortBy == taskPropertyPosition {
|
||||
param.sortBy = "positions.view_" + strconv.FormatInt(param.projectViewID, 10)
|
||||
continue
|
||||
sortBy = "positions.view_" + strconv.FormatInt(param.projectViewID, 10)
|
||||
projectViewIDForPosition = param.projectViewID
|
||||
}
|
||||
|
||||
sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String())
|
||||
sortbyFields = append(sortbyFields, sortBy+"(missing_values:last):"+param.orderBy.String())
|
||||
|
||||
if i == 2 {
|
||||
// Typesense supports up to 3 sorting parameters
|
||||
|
@ -525,9 +533,14 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = t.s.
|
||||
query := t.s.
|
||||
In("id", taskIDs).
|
||||
OrderBy(orderby).
|
||||
Find(&tasks)
|
||||
OrderBy(orderby)
|
||||
|
||||
if projectViewIDForPosition != 0 {
|
||||
query = query.Join("LEFT", "task_positions", "task_positions.task_id = tasks.id AND task_positions.project_view_id = ?", projectViewIDForPosition)
|
||||
}
|
||||
|
||||
err = query.Find(&tasks)
|
||||
return tasks, int64(*result.Found), err
|
||||
}
|
||||
|
|
|
@ -1486,6 +1486,18 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
// Delete all positions
|
||||
_, err = s.Where("task_id = ?", t.ID).Delete(&TaskPosition{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all bucket relations
|
||||
_, err = s.Where("task_id = ?", t.ID).Delete(&TaskBucket{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Actually delete the task
|
||||
_, err = s.ID(t.ID).Delete(Task{})
|
||||
if err != nil {
|
||||
|
|
|
@ -146,22 +146,10 @@ func CreateTypesenseCollections() error {
|
|||
Name: "updated",
|
||||
Type: "int64", // unix timestamp
|
||||
},
|
||||
{
|
||||
Name: "bucket_id",
|
||||
Type: "int64",
|
||||
},
|
||||
{
|
||||
Name: "position",
|
||||
Type: "float",
|
||||
},
|
||||
{
|
||||
Name: "created_by_id",
|
||||
Type: "int64",
|
||||
},
|
||||
{
|
||||
Name: "project_view_id",
|
||||
Type: "int64",
|
||||
},
|
||||
{
|
||||
Name: "reminders",
|
||||
Type: "object[]", // TODO
|
||||
|
@ -192,6 +180,14 @@ func CreateTypesenseCollections() error {
|
|||
Type: "object[]", // TODO
|
||||
Optional: pointer.True(),
|
||||
},
|
||||
{
|
||||
Name: "positions",
|
||||
Type: "object",
|
||||
},
|
||||
{
|
||||
Name: "buckets",
|
||||
Type: "int64[]",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -248,14 +244,8 @@ func ReindexAllTasks() (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project) (ttask *typesenseTask, err error) {
|
||||
positions := []*TaskPosition{}
|
||||
err = s.Where("task_id = ?", task.ID).Find(&positions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ttask = convertTaskToTypesenseTask(task, positions)
|
||||
func getTypesenseTaskForTask(s *xorm.Session, task *Task, projectsCache map[int64]*Project, taskPositionCache map[int64][]*TaskPositionWithView, taskBucketCache map[int64][]*TaskBucket) (ttask *typesenseTask, err error) {
|
||||
ttask = convertTaskToTypesenseTask(task, taskPositionCache[task.ID], taskBucketCache[task.ID])
|
||||
|
||||
var p *Project
|
||||
if projectsCache == nil {
|
||||
|
@ -299,9 +289,19 @@ func reindexTasksInTypesense(s *xorm.Session, tasks map[int64]*Task) (err error)
|
|||
projects := make(map[int64]*Project)
|
||||
typesenseTasks := []interface{}{}
|
||||
|
||||
positionsByTask, err := getPositionsByTask(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bucketsByTask, err := getBucketsByTask(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
|
||||
ttask, err := getTypesenseTaskForTask(s, task, projects)
|
||||
ttask, err := getTypesenseTaskForTask(s, task, projects, positionsByTask, bucketsByTask)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -320,11 +320,55 @@ func reindexTasksInTypesense(s *xorm.Session, tasks map[int64]*Task) (err error)
|
|||
return err
|
||||
}
|
||||
|
||||
log.Debugf("Indexed tasks %v into Typesense", tasks)
|
||||
log.Debugf("Indexed %d tasks into Typesense", len(tasks))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type TaskPositionWithView struct {
|
||||
ProjectView `xorm:"extends"`
|
||||
TaskPosition `xorm:"extends"`
|
||||
}
|
||||
|
||||
func getPositionsByTask(s *xorm.Session) (positionsByTask map[int64][]*TaskPositionWithView, err error) {
|
||||
rawPositions := []*TaskPositionWithView{}
|
||||
err = s.
|
||||
Table("project_views").
|
||||
Join("LEFT", "task_positions", "project_views.id = task_positions.project_view_id").
|
||||
Find(&rawPositions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
positionsByTask = make(map[int64][]*TaskPositionWithView, len(rawPositions))
|
||||
for _, p := range rawPositions {
|
||||
_, has := positionsByTask[p.TaskID]
|
||||
if !has {
|
||||
positionsByTask[p.TaskID] = []*TaskPositionWithView{}
|
||||
}
|
||||
positionsByTask[p.TaskID] = append(positionsByTask[p.TaskID], p)
|
||||
}
|
||||
return positionsByTask, nil
|
||||
}
|
||||
|
||||
func getBucketsByTask(s *xorm.Session) (positionsByTask map[int64][]*TaskBucket, err error) {
|
||||
rawBuckets := []*TaskBucket{}
|
||||
err = s.Find(&rawBuckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
positionsByTask = make(map[int64][]*TaskBucket, len(rawBuckets))
|
||||
for _, p := range rawBuckets {
|
||||
_, has := positionsByTask[p.TaskID]
|
||||
if !has {
|
||||
positionsByTask[p.TaskID] = []*TaskBucket{}
|
||||
}
|
||||
positionsByTask[p.TaskID] = append(positionsByTask[p.TaskID], p)
|
||||
}
|
||||
return positionsByTask, nil
|
||||
}
|
||||
|
||||
func indexDummyTask() (err error) {
|
||||
// The initial sync should contain one dummy task with all related fields populated so that typesense
|
||||
// creates the indexes properly. A little hacky, but gets the job done.
|
||||
|
@ -386,6 +430,13 @@ func indexDummyTask() (err error) {
|
|||
},
|
||||
},
|
||||
},
|
||||
Positions: map[string]float64{
|
||||
"view_1": 10,
|
||||
"view_2": 30,
|
||||
"view_3": 5450,
|
||||
"view_4": 42,
|
||||
},
|
||||
Buckets: []int64{42},
|
||||
}
|
||||
|
||||
_, err = typesenseClient.Collection("tasks").
|
||||
|
@ -430,9 +481,10 @@ type typesenseTask struct {
|
|||
Attachments interface{} `json:"attachments"`
|
||||
Comments interface{} `json:"comments"`
|
||||
Positions map[string]float64 `json:"positions"`
|
||||
Buckets []int64 `json:"buckets"`
|
||||
}
|
||||
|
||||
func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesenseTask {
|
||||
func convertTaskToTypesenseTask(task *Task, positions []*TaskPositionWithView, buckets []*TaskBucket) *typesenseTask {
|
||||
|
||||
tt := &typesenseTask{
|
||||
ID: fmt.Sprintf("%d", task.ID),
|
||||
|
@ -461,6 +513,8 @@ func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesens
|
|||
Labels: task.Labels,
|
||||
//RelatedTasks: task.RelatedTasks,
|
||||
Attachments: task.Attachments,
|
||||
Positions: make(map[string]float64, len(positions)),
|
||||
Buckets: make([]int64, 0, len(buckets)),
|
||||
}
|
||||
|
||||
if task.DoneAt.IsZero() {
|
||||
|
@ -477,7 +531,15 @@ func convertTaskToTypesenseTask(task *Task, positions []*TaskPosition) *typesens
|
|||
}
|
||||
|
||||
for _, position := range positions {
|
||||
tt.Positions["view_"+strconv.FormatInt(position.ProjectViewID, 10)] = position.Position
|
||||
pos := position.TaskPosition.Position
|
||||
if pos == 0 {
|
||||
pos = float64(task.ID)
|
||||
}
|
||||
tt.Positions["view_"+strconv.FormatInt(position.ProjectView.ID, 10)] = pos
|
||||
}
|
||||
|
||||
for _, bucket := range buckets {
|
||||
tt.Buckets = append(tt.Buckets, bucket.BucketID)
|
||||
}
|
||||
|
||||
return tt
|
||||
|
|
|
@ -64,6 +64,11 @@ func insertFromStructure(s *xorm.Session, str []*models.ProjectWithTasksAndBucke
|
|||
}
|
||||
|
||||
p.ID = 0
|
||||
|
||||
for _, view := range p.Views {
|
||||
view.ProjectID = 0
|
||||
}
|
||||
|
||||
err = createProject(s, p, &archivedProjects, labels, user)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -167,7 +172,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
}
|
||||
|
||||
// Create all buckets
|
||||
buckets := make(map[int64]*models.Bucket) // old bucket id is the key
|
||||
bucketsByOldID := make(map[int64]*models.Bucket) // old bucket id is the key
|
||||
if len(project.Buckets) > 0 {
|
||||
log.Debugf("[creating structure] Creating %d buckets", len(project.Buckets))
|
||||
}
|
||||
|
@ -179,40 +184,45 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
buckets[oldID] = bucket
|
||||
bucketsByOldID[oldID] = bucket
|
||||
log.Debugf("[creating structure] Created bucket %d, old ID was %d", bucket.ID, oldID)
|
||||
}
|
||||
|
||||
// Create all views, create default views if we don't have any
|
||||
viewsByOldIDs := make(map[int64]*models.ProjectView, len(oldViews))
|
||||
if len(oldViews) > 0 {
|
||||
for _, view := range oldViews {
|
||||
oldID := view.ID
|
||||
view.ID = 0
|
||||
|
||||
if view.DefaultBucketID != 0 {
|
||||
bucket, has := buckets[view.DefaultBucketID]
|
||||
bucket, has := bucketsByOldID[view.DefaultBucketID]
|
||||
if has {
|
||||
view.DefaultBucketID = bucket.ID
|
||||
}
|
||||
}
|
||||
|
||||
if view.DoneBucketID != 0 {
|
||||
bucket, has := buckets[view.DoneBucketID]
|
||||
bucket, has := bucketsByOldID[view.DoneBucketID]
|
||||
if has {
|
||||
view.DoneBucketID = bucket.ID
|
||||
}
|
||||
}
|
||||
|
||||
view.ProjectID = project.ID
|
||||
|
||||
err = view.Create(s, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
viewsByOldIDs[oldID] = view
|
||||
}
|
||||
} else {
|
||||
// Only using the default views
|
||||
// Add all buckets to the default kanban view
|
||||
for _, view := range project.Views {
|
||||
if view.ViewKind == models.ProjectViewKindKanban {
|
||||
for _, b := range buckets {
|
||||
for _, b := range bucketsByOldID {
|
||||
b.ProjectViewID = view.ID
|
||||
err = b.Update(s, user)
|
||||
if err != nil {
|
||||
|
@ -227,7 +237,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
log.Debugf("[creating structure] Creating %d tasks", len(tasks))
|
||||
|
||||
setBucketOrDefault := func(task *models.Task) {
|
||||
bucket, exists := buckets[task.BucketID]
|
||||
bucket, exists := bucketsByOldID[task.BucketID]
|
||||
if exists {
|
||||
task.BucketID = bucket.ID
|
||||
} else if task.BucketID > 0 {
|
||||
|
@ -240,6 +250,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
}
|
||||
|
||||
tasksByOldID := make(map[int64]*models.TaskWithComments, len(tasks))
|
||||
newTaskIDs := []int64{}
|
||||
// Create all tasks
|
||||
for i, t := range tasks {
|
||||
setBucketOrDefault(&tasks[i].Task)
|
||||
|
@ -251,6 +262,8 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
continue
|
||||
}
|
||||
|
||||
newTaskIDs = append(newTaskIDs, t.ID)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -359,10 +372,11 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
log.Debugf("[creating structure] Associated task %d with label %d", t.ID, lb.ID)
|
||||
}
|
||||
|
||||
// Comments
|
||||
for _, comment := range t.Comments {
|
||||
comment.TaskID = t.ID
|
||||
comment.ID = 0
|
||||
err = comment.Create(s, user)
|
||||
err = comment.CreateWithTimestamps(s, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -400,6 +414,58 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
|
|||
}
|
||||
}
|
||||
|
||||
if len(viewsByOldIDs) > 0 {
|
||||
newPositions := []*models.TaskPosition{}
|
||||
for _, pos := range project.Positions {
|
||||
_, hasTask := tasksByOldID[pos.TaskID]
|
||||
_, hasView := viewsByOldIDs[pos.ProjectViewID]
|
||||
if !hasTask || !hasView {
|
||||
continue
|
||||
}
|
||||
newPositions = append(newPositions, &models.TaskPosition{
|
||||
TaskID: tasksByOldID[pos.TaskID].ID,
|
||||
ProjectViewID: viewsByOldIDs[pos.ProjectViewID].ID,
|
||||
Position: pos.Position,
|
||||
})
|
||||
}
|
||||
|
||||
if len(newPositions) > 0 {
|
||||
_, err = s.In("task_id", newTaskIDs).Delete(&models.TaskPosition{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = s.Insert(newPositions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newTaskBuckets := make([]*models.TaskBucket, 0, len(project.TaskBuckets))
|
||||
for _, tb := range project.TaskBuckets {
|
||||
_, hasTask := tasksByOldID[tb.TaskID]
|
||||
_, hasBucket := bucketsByOldID[tb.BucketID]
|
||||
if !hasTask || !hasBucket {
|
||||
continue
|
||||
}
|
||||
newTaskBuckets = append(newTaskBuckets, &models.TaskBucket{
|
||||
TaskID: tasksByOldID[tb.TaskID].ID,
|
||||
BucketID: bucketsByOldID[tb.BucketID].ID,
|
||||
ProjectViewID: bucketsByOldID[tb.BucketID].ProjectViewID,
|
||||
})
|
||||
}
|
||||
|
||||
if len(newTaskBuckets) > 0 {
|
||||
_, err = s.In("task_id", newTaskIDs).Delete(&models.TaskBucket{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = s.Insert(newTaskBuckets)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
project.Tasks = tasks
|
||||
project.Buckets = originalBuckets
|
||||
|
||||
|
|
|
@ -156,10 +156,18 @@ func setupSentry(e *echo.Echo) {
|
|||
if hub != nil {
|
||||
hub.WithScope(func(scope *sentry.Scope) {
|
||||
scope.SetExtra("url", c.Request().URL)
|
||||
hub.CaptureException(herr.Internal)
|
||||
if herr.Internal == nil {
|
||||
hub.CaptureException(err)
|
||||
} else {
|
||||
hub.CaptureException(herr.Internal)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
sentry.CaptureException(herr.Internal)
|
||||
if herr.Internal == nil {
|
||||
sentry.CaptureException(err)
|
||||
} else {
|
||||
sentry.CaptureException(herr.Internal)
|
||||
}
|
||||
log.Debugf("Could not add context for sending error '%s' to sentry", err.Error())
|
||||
}
|
||||
log.Debugf("Error '%s' sent to sentry", err.Error())
|
||||
|
|
Loading…
Reference in New Issue