Merge remote-tracking branch 'upstream/main' into landing-page
This commit is contained in:
commit
290bf8b280
|
|
@ -67,18 +67,25 @@ jobs:
|
|||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||
- name: Comment on PR
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const fullSha = context.payload.pull_request.head.sha;
|
||||
const shortSha = fullSha.substring(0, 7);
|
||||
const base = 'preview.vikunja.dev';
|
||||
const image = `ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}`;
|
||||
const marker = '<!-- vikunja-preview-comment -->';
|
||||
|
||||
// Extract the SHA tag from docker meta output (the actual tag pushed to GHCR)
|
||||
const metaTags = process.env.DOCKER_META_TAGS.split('\n').map(t => t.trim()).filter(Boolean);
|
||||
const shaImageRef = metaTags.find(t => t.includes(':sha-'));
|
||||
const shaTag = shaImageRef ? shaImageRef.split(':').pop() : null;
|
||||
const shortSha = shaTag ? shaTag.replace('sha-', '').substring(0, 7) : context.payload.pull_request.head.sha.substring(0, 7);
|
||||
|
||||
const prTag = `pr-${prNumber}`;
|
||||
const shaTag = `sha-${fullSha}`;
|
||||
const newShaRow = `| https://${shaTag}.${base} | \`${image}:${shaTag}\` | \`${shortSha}\` |`;
|
||||
const newShaRow = shaTag
|
||||
? `| https://${shaTag}.${base} | \`${image}:${shaTag}\` | \`${shortSha}\` |`
|
||||
: '';
|
||||
|
||||
// Collect previous SHA rows from existing comment
|
||||
let previousShaRows = [];
|
||||
|
|
@ -96,9 +103,11 @@ jobs:
|
|||
}
|
||||
|
||||
// Remove duplicate if this SHA was already recorded
|
||||
previousShaRows = previousShaRows.filter(r => !r.includes(shaTag));
|
||||
if (shaTag) {
|
||||
previousShaRows = previousShaRows.filter(r => !r.includes(shaTag));
|
||||
}
|
||||
|
||||
const allShaRows = [newShaRow, ...previousShaRows].join('\n');
|
||||
const allShaRows = [newShaRow, ...previousShaRows].filter(Boolean).join('\n');
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
name: Close stale "waiting for reply" issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
only-labels: 'waiting for reply'
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 0
|
||||
stale-issue-label: 'waiting for reply'
|
||||
remove-stale-when-updated: false
|
||||
close-issue-message: >
|
||||
Closing this for now since we haven't heard back on the follow-up
|
||||
questions. If you're still seeing this on a recent version, just
|
||||
drop a comment with the requested info and we'll reopen. Thanks
|
||||
for the report!
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 100
|
||||
|
|
@ -178,16 +178,6 @@ jobs:
|
|||
test:
|
||||
- feature
|
||||
- web
|
||||
- e2e-api
|
||||
exclude:
|
||||
- db: sqlite
|
||||
test: e2e-api
|
||||
- db: postgres
|
||||
test: e2e-api
|
||||
- db: mysql
|
||||
test: e2e-api
|
||||
- db: paradedb
|
||||
test: e2e-api
|
||||
services:
|
||||
db-mysql:
|
||||
image: ${{ matrix.db == 'mysql' && 'mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8' || '' }}
|
||||
|
|
@ -256,6 +246,48 @@ jobs:
|
|||
chmod +x mage-static
|
||||
./mage-static test:${{ matrix.test }}
|
||||
|
||||
test-caldav:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
run: |
|
||||
mkdir -p frontend/dist
|
||||
touch frontend/dist/index.html
|
||||
chmod +x mage-static
|
||||
./mage-static test:caldav
|
||||
|
||||
test-e2e-api:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
run: |
|
||||
mkdir -p frontend/dist
|
||||
touch frontend/dist/index.html
|
||||
chmod +x mage-static
|
||||
./mage-static test:e2e-api
|
||||
|
||||
test-s3-integration:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
|
|
|
|||
|
|
@ -941,8 +941,8 @@ packages:
|
|||
lodash.union@4.6.0:
|
||||
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
|
||||
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
||||
|
|
@ -1723,7 +1723,7 @@ snapshots:
|
|||
dependencies:
|
||||
debug: 4.4.3
|
||||
fs-extra: 9.1.0
|
||||
lodash: 4.17.23
|
||||
lodash: 4.18.1
|
||||
tmp-promise: 3.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -2806,7 +2806,7 @@ snapshots:
|
|||
|
||||
lodash.union@4.6.0: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
lodash@4.18.1: {}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@
|
|||
"dompurify": "3.3.2",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"flexsearch": "0.8.212",
|
||||
"floating-vue": "5.2.2",
|
||||
"is-touch-device": "1.0.1",
|
||||
"klona": "2.0.6",
|
||||
|
|
@ -114,10 +113,11 @@
|
|||
"@tsconfig/node24": "24.0.4",
|
||||
"@types/codemirror": "5.60.17",
|
||||
"@types/is-touch-device": "1.0.3",
|
||||
"@types/node": "24.12.0",
|
||||
"@types/node": "24.12.2",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.0",
|
||||
"@typescript-eslint/parser": "8.58.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"@typescript-eslint/parser": "8.58.1",
|
||||
"@vitejs/plugin-vue": "6.0.5",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/test-utils": "2.4.6",
|
||||
|
|
@ -125,20 +125,20 @@
|
|||
"@vueuse/shared": "14.2.1",
|
||||
"autoprefixer": "10.4.27",
|
||||
"browserslist": "4.28.2",
|
||||
"caniuse-lite": "1.0.30001784",
|
||||
"caniuse-lite": "1.0.30001787",
|
||||
"csstype": "3.2.3",
|
||||
"esbuild": "0.27.5",
|
||||
"esbuild": "0.28.0",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-plugin-depend": "1.5.0",
|
||||
"eslint-plugin-vue": "10.8.0",
|
||||
"happy-dom": "20.8.9",
|
||||
"histoire": "1.0.0-beta.1",
|
||||
"postcss": "8.5.8",
|
||||
"postcss": "8.5.9",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-preset-env": "11.2.0",
|
||||
"rollup": "4.60.1",
|
||||
"rollup-plugin-visualizer": "6.0.11",
|
||||
"sass-embedded": "1.98.0",
|
||||
"sass-embedded": "1.99.0",
|
||||
"stylelint": "17.6.0",
|
||||
"stylelint-config-property-sort-order-smacss": "10.0.0",
|
||||
"stylelint-config-recommended-vue": "1.6.1",
|
||||
|
|
@ -147,14 +147,15 @@
|
|||
"tailwindcss": "4.2.2",
|
||||
"typescript": "5.9.3",
|
||||
"unplugin-inject-preload": "3.0.0",
|
||||
"vite": "7.3.1",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-pwa": "1.2.0",
|
||||
"vite-plugin-vue-devtools": "8.1.1",
|
||||
"vite-svg-loader": "5.1.1",
|
||||
"vitest": "4.1.2",
|
||||
"vitest": "4.1.3",
|
||||
"vue-tsc": "3.2.6",
|
||||
"wait-on": "9.0.4",
|
||||
"workbox-cli": "7.4.0"
|
||||
"workbox-cli": "7.4.0",
|
||||
"ws": "8.20.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -751,6 +751,7 @@ onUnmounted(() => {
|
|||
<style scoped lang="scss">
|
||||
.gantt-container {
|
||||
overflow-x: auto;
|
||||
min-inline-size: 100%;
|
||||
}
|
||||
|
||||
.gantt-chart-wrapper {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ import {useProjectStore} from '@/stores/projects'
|
|||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
||||
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||
import {useWebSocket} from '@/composables/useWebSocket'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
|
@ -136,6 +137,9 @@ watch(() => route.name as string, (routeName) => {
|
|||
|
||||
useRenewTokenOnFocus()
|
||||
|
||||
const {connect} = useWebSocket()
|
||||
connect()
|
||||
|
||||
const labelStore = useLabelStore()
|
||||
labelStore.loadAllLabels()
|
||||
|
||||
|
|
|
|||
|
|
@ -18,15 +18,6 @@
|
|||
:class="{ 'project-is-collapsed': !childProjectsOpen }"
|
||||
/>
|
||||
</BaseButton>
|
||||
<span
|
||||
v-if="canEditOrder && project.id > 0 && project.maxPermission !== null && project.maxPermission > PERMISSIONS.READ"
|
||||
class="icon menu-item-icon handle drag-handle-standalone"
|
||||
@mousedown.stop
|
||||
@click.stop.prevent
|
||||
@touchstart.stop
|
||||
>
|
||||
<Icon icon="grip-lines" />
|
||||
</span>
|
||||
<BaseButton
|
||||
:to="{ name: 'project.index', params: { projectId: project.id} }"
|
||||
class="list-menu-link"
|
||||
|
|
@ -48,6 +39,15 @@
|
|||
>
|
||||
<Icon icon="filter" />
|
||||
</span>
|
||||
<span
|
||||
v-if="canEditOrder && project.id > 0 && project.maxPermission !== null && project.maxPermission > PERMISSIONS.READ"
|
||||
class="icon menu-item-icon handle drag-handle"
|
||||
@mousedown.stop
|
||||
@click.stop.prevent
|
||||
@touchstart.stop
|
||||
>
|
||||
<Icon icon="grip-lines" />
|
||||
</span>
|
||||
</div>
|
||||
<span class="project-menu-title">{{ getProjectTitle(project) }}</span>
|
||||
</BaseButton>
|
||||
|
|
@ -221,7 +221,7 @@ const canToggleFavorite = computed(() => {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-menu:hover > div > .drag-handle-standalone {
|
||||
.list-menu:hover .color-bubble-wrapper > .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
|
@ -252,16 +252,15 @@ const canToggleFavorite = computed(() => {
|
|||
}
|
||||
}
|
||||
|
||||
.drag-handle-standalone {
|
||||
inline-size: 1rem;
|
||||
block-size: 1rem;
|
||||
.drag-handle {
|
||||
opacity: 0;
|
||||
cursor: grab;
|
||||
transition: opacity $transition;
|
||||
z-index: 2;
|
||||
|
||||
position: absolute;
|
||||
inset-inline-start: 2.15rem;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
|
|
@ -279,7 +278,7 @@ const canToggleFavorite = computed(() => {
|
|||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.drag-handle-standalone {
|
||||
.drag-handle {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
faEyeSlash,
|
||||
faFile,
|
||||
faFileImage,
|
||||
faFilePdf,
|
||||
faFillDrip,
|
||||
faFilter,
|
||||
faForward,
|
||||
|
|
@ -111,6 +112,7 @@ library.add(faSquareCheck)
|
|||
library.add(faTable)
|
||||
library.add(faFile)
|
||||
library.add(faFileImage)
|
||||
library.add(faFilePdf)
|
||||
library.add(faCheckSquare)
|
||||
library.add(faStrikethrough)
|
||||
library.add(faCode)
|
||||
|
|
|
|||
|
|
@ -83,10 +83,11 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, onUnmounted, ref} from 'vue'
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||
import {useRouter, isNavigationFailure, NavigationFailureType, RouteLocationRaw} from 'vue-router'
|
||||
|
||||
import NotificationService from '@/services/notification'
|
||||
import NotificationModel from '@/models/notification'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import User from '@/components/misc/User.vue'
|
||||
|
|
@ -95,11 +96,12 @@ import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
|||
import {formatDateLong, formatDisplayDate} from '@/helpers/time/formatDate'
|
||||
import {getDisplayName} from '@/models/user'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useWebSocket} from '@/composables/useWebSocket'
|
||||
import XButton from '@/components/input/Button.vue'
|
||||
import {success} from '@/message'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const LOAD_NOTIFICATIONS_INTERVAL = 10000
|
||||
const {subscribe, connected: wsConnected} = useWebSocket()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
|
@ -117,26 +119,68 @@ const notifications = computed(() => {
|
|||
})
|
||||
const userInfo = computed(() => authStore.info)
|
||||
|
||||
let interval: ReturnType<typeof setInterval>
|
||||
let unsubscribeWs: (() => void) | null = null
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const POLL_INTERVAL = 10000
|
||||
|
||||
onMounted(async () => {
|
||||
// Initial load via REST - wrapped in try/catch so the rest of setup
|
||||
// (click handler, WS subscription, polling) still runs if this fails
|
||||
try {
|
||||
await loadNotifications()
|
||||
} catch (e) {
|
||||
console.warn('Failed to load initial notifications:', e)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNotifications()
|
||||
document.addEventListener('click', hidePopup)
|
||||
document.addEventListener('visibilitychange', loadNotifications)
|
||||
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
|
||||
|
||||
// Subscribe to real-time notifications
|
||||
unsubscribeWs = subscribe('notification.created', (msg) => {
|
||||
if (msg.event === 'notification.created' && msg.data) {
|
||||
const notification = new NotificationModel(msg.data as Partial<INotification>)
|
||||
// Avoid duplicates if the same notification was already loaded via REST
|
||||
const exists = allNotifications.value.some(n => n.id === notification.id)
|
||||
if (!exists) {
|
||||
allNotifications.value = [notification, ...allNotifications.value]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback polling when WebSocket is not available
|
||||
startPollingFallback()
|
||||
})
|
||||
|
||||
// Reload notifications when WebSocket disconnects to catch any events
|
||||
// that may have been missed during the disconnect window
|
||||
watch(wsConnected, (isConnected, wasConnected) => {
|
||||
if (wasConnected && !isConnected) {
|
||||
loadNotifications().catch(e => console.warn('Failed to reload notifications after WS disconnect:', e))
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', hidePopup)
|
||||
document.removeEventListener('visibilitychange', loadNotifications)
|
||||
clearInterval(interval)
|
||||
unsubscribeWs?.()
|
||||
stopPollingFallback()
|
||||
})
|
||||
|
||||
async function loadNotifications() {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return
|
||||
function startPollingFallback() {
|
||||
pollInterval = setInterval(async () => {
|
||||
if (!wsConnected.value && document.visibilityState === 'visible') {
|
||||
await loadNotifications()
|
||||
}
|
||||
}, POLL_INTERVAL)
|
||||
}
|
||||
|
||||
function stopPollingFallback() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
// We're recreating the notification service here to make sure it uses the latest api user token
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
const notificationService = new NotificationService()
|
||||
allNotifications.value = await notificationService.getAll()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,10 +55,6 @@
|
|||
</Card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export const ALPHABETICAL_SORT = 'title'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
<template>
|
||||
<Popup>
|
||||
<template #trigger="{toggle}">
|
||||
<XButton
|
||||
variant="secondary"
|
||||
icon="sort"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ $t('project.list.sort') }}
|
||||
</XButton>
|
||||
</template>
|
||||
<template #content="{close}">
|
||||
<Card class="sort-popup">
|
||||
<p class="sort-description has-text-grey is-size-7">
|
||||
{{ $t('sorting.description') }}
|
||||
</p>
|
||||
<div class="field">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="selected">
|
||||
<option
|
||||
v-for="o in options"
|
||||
:key="o.value"
|
||||
:value="o.value"
|
||||
>
|
||||
{{ o.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<XButton
|
||||
variant="tertiary"
|
||||
@click="close()"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
variant="primary"
|
||||
@click="applySort(close)"
|
||||
>
|
||||
{{ $t('sorting.apply') }}
|
||||
</XButton>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</Popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import XButton from '@/components/input/Button.vue'
|
||||
import Popup from '@/components/misc/Popup.vue'
|
||||
import Card from '@/components/misc/Card.vue'
|
||||
import type {SortBy} from '@/composables/useTaskList'
|
||||
|
||||
const props = defineProps<{ modelValue: SortBy }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: SortBy] }>()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const MANUAL = 'position:asc'
|
||||
const selected = ref<string>(MANUAL)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
const key = Object.keys(val)[0]
|
||||
if (!key || key === 'position') {
|
||||
selected.value = MANUAL
|
||||
return
|
||||
}
|
||||
const order = (val as Record<string, 'asc' | 'desc'>)[key] ?? 'asc'
|
||||
selected.value = `${key}:${order}`
|
||||
}, {immediate: true})
|
||||
|
||||
const options = computed(() => {
|
||||
const manual = {value: MANUAL, label: t('sorting.manually')}
|
||||
const rest = [
|
||||
{value: 'title:asc', label: t('sorting.options.titleAsc')},
|
||||
{value: 'title:desc', label: t('sorting.options.titleDesc')},
|
||||
{value: 'priority:desc', label: t('sorting.options.priorityDesc')},
|
||||
{value: 'priority:asc', label: t('sorting.options.priorityAsc')},
|
||||
{value: 'due_date:asc', label: t('sorting.options.dueDateAsc')},
|
||||
{value: 'due_date:desc', label: t('sorting.options.dueDateDesc')},
|
||||
{value: 'start_date:asc', label: t('sorting.options.startDateAsc')},
|
||||
{value: 'start_date:desc', label: t('sorting.options.startDateDesc')},
|
||||
{value: 'end_date:asc', label: t('sorting.options.endDateAsc')},
|
||||
{value: 'end_date:desc', label: t('sorting.options.endDateDesc')},
|
||||
{value: 'percent_done:desc', label: t('sorting.options.percentDoneDesc')},
|
||||
{value: 'percent_done:asc', label: t('sorting.options.percentDoneAsc')},
|
||||
{value: 'created:desc', label: t('sorting.options.createdDesc')},
|
||||
{value: 'created:asc', label: t('sorting.options.createdAsc')},
|
||||
{value: 'updated:desc', label: t('sorting.options.updatedDesc')},
|
||||
{value: 'updated:asc', label: t('sorting.options.updatedAsc')},
|
||||
].sort((a, b) => a.label.localeCompare(b.label))
|
||||
|
||||
return [manual, ...rest]
|
||||
})
|
||||
|
||||
function applySort(close: () => void) {
|
||||
const [field, order] = selected.value.split(':') as [string, 'asc' | 'desc']
|
||||
const sort: SortBy = {} as SortBy
|
||||
;(sort as Record<string, 'asc' | 'desc'>)[field] = order
|
||||
emit('update:modelValue', sort)
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sort-popup {
|
||||
margin: 0;
|
||||
min-inline-size: 18rem;
|
||||
|
||||
:deep(.card-content .content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sort-description {
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: .5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -146,13 +146,11 @@ const flatPickerDateRange = computed<Date[]>({
|
|||
},
|
||||
})
|
||||
|
||||
const initialDateRange = [filters.value.dateFrom, filters.value.dateTo]
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatShort'),
|
||||
altInput: true,
|
||||
defaultDate: initialDateRange,
|
||||
defaultDate: [filters.value.dateFrom, filters.value.dateTo],
|
||||
enableTime: false,
|
||||
mode: 'range',
|
||||
locale: useFlatpickrLanguage().value,
|
||||
|
|
@ -162,6 +160,8 @@ const flatPickerConfig = computed(() => ({
|
|||
<style lang="scss" scoped>
|
||||
.gantt-chart-container {
|
||||
padding-block-end: 1rem;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.gantt-options {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@
|
|||
>
|
||||
<template #header>
|
||||
<div class="filter-container">
|
||||
<SortPopup
|
||||
v-model="sortByParam"
|
||||
/>
|
||||
<FilterPopup
|
||||
v-if="!isSavedFilter(project)"
|
||||
v-model="params"
|
||||
:view-id="viewId"
|
||||
:project-id="projectId"
|
||||
@update:modelValue="prepareFiltersAndLoadTasks()"
|
||||
@update:modelValue="loadTasks()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -49,13 +52,13 @@
|
|||
v-if="tasks && tasks.length > 0"
|
||||
v-model="tasks"
|
||||
:group="{name: 'tasks', put: false}"
|
||||
:disabled="!canDragTasks"
|
||||
:disabled="!canDragTasks || !isPositionSorting"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
:component-data="{
|
||||
class: {
|
||||
tasks: true,
|
||||
'dragging-disabled': !canDragTasks || isAlphabeticalSorting
|
||||
'dragging-disabled': !canDragTasks || !isPositionSorting
|
||||
},
|
||||
type: 'transition-group'
|
||||
}"
|
||||
|
|
@ -71,14 +74,13 @@
|
|||
<SingleTaskInProject
|
||||
:ref="(el) => setTaskRef(el, index)"
|
||||
:show-list-color="false"
|
||||
:disabled="!canDragTasks"
|
||||
:can-mark-as-done="canWrite || isPseudoProject"
|
||||
:the-task="t"
|
||||
:all-tasks="allTasks"
|
||||
@taskUpdated="updateTasks"
|
||||
>
|
||||
<span
|
||||
v-if="canDragTasks"
|
||||
v-if="canDragTasks && isPositionSorting"
|
||||
class="icon handle"
|
||||
>
|
||||
<Icon icon="grip-lines" />
|
||||
|
|
@ -109,7 +111,7 @@ import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject
|
|||
import FilterPopup from '@/components/project/partials/FilterPopup.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 SortPopup from '@/components/project/partials/SortPopup.vue'
|
||||
|
||||
import {useTaskList} from '@/composables/useTaskList'
|
||||
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
|
||||
|
|
@ -167,13 +169,12 @@ const tasks = ref<ITask[]>([])
|
|||
watch(
|
||||
allTasks,
|
||||
() => {
|
||||
tasks.value = ([...allTasks.value]).filter(t => shouldShowTaskInListView(t, allTasks.value))
|
||||
const isFiltered = isSavedFilter({id: projectId.value})
|
||||
tasks.value = ([...allTasks.value]).filter(t => shouldShowTaskInListView(t, allTasks.value, isFiltered))
|
||||
},
|
||||
)
|
||||
|
||||
const isAlphabeticalSorting = computed(() => {
|
||||
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
|
||||
})
|
||||
const isPositionSorting = computed(() => 'position' in sortByParam.value)
|
||||
|
||||
const firstNewPosition = computed(() => {
|
||||
if (tasks.value.length === 0) {
|
||||
|
|
@ -214,7 +215,7 @@ function focusNewTaskInput() {
|
|||
}
|
||||
|
||||
function updateTaskList(task: ITask) {
|
||||
if (isAlphabeticalSorting.value) {
|
||||
if (!isPositionSorting.value) {
|
||||
// reload tasks with current filter and sorting
|
||||
loadTasks()
|
||||
} else {
|
||||
|
|
@ -286,15 +287,6 @@ async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement
|
|||
}
|
||||
}
|
||||
|
||||
function prepareFiltersAndLoadTasks() {
|
||||
if (isAlphabeticalSorting.value) {
|
||||
sortByParam.value = {}
|
||||
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
|
||||
}
|
||||
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const taskRefs = ref<(InstanceType<typeof SingleTaskInProject> | null)[]>([])
|
||||
const focusedIndex = ref(-1)
|
||||
|
||||
|
|
@ -364,6 +356,18 @@ onBeforeUnmount(() => {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
|
||||
:deep(.popup) {
|
||||
inset-block-start: 3rem;
|
||||
inset-inline-end: 0;
|
||||
max-inline-size: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.tasks {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@
|
|||
<Icon icon="trash-alt" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="editEnabled && canPreview(a)"
|
||||
v-if="editEnabled && canPreviewImage(a)"
|
||||
v-tooltip="task.coverImageAttachmentId === a.id
|
||||
? $t('task.attachment.unsetAsCover')
|
||||
: $t('task.attachment.setAsCover')"
|
||||
|
|
@ -168,6 +168,19 @@
|
|||
alt=""
|
||||
>
|
||||
</Modal>
|
||||
|
||||
<!-- Attachment PDF modal -->
|
||||
<Modal
|
||||
:enabled="attachmentPdfBlobUrl !== null"
|
||||
:wide="true"
|
||||
@close="attachmentPdfBlobUrl = null"
|
||||
>
|
||||
<iframe
|
||||
v-if="attachmentPdfBlobUrl"
|
||||
:src="attachmentPdfBlobUrl"
|
||||
class="pdf-preview-iframe"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -180,7 +193,7 @@ import ProgressBar from '@/components/misc/ProgressBar.vue'
|
|||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import AttachmentService from '@/services/attachment'
|
||||
import {canPreview} from '@/models/attachment'
|
||||
import {canPreviewImage, canPreviewPdf} from '@/models/attachment'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
|
|
@ -365,10 +378,13 @@ async function deleteAttachment() {
|
|||
}
|
||||
|
||||
const attachmentImageBlobUrl = ref<string | null>(null)
|
||||
const attachmentPdfBlobUrl = ref<string | null>(null)
|
||||
|
||||
async function viewOrDownload(attachment: IAttachment) {
|
||||
if (canPreview(attachment)) {
|
||||
if (canPreviewImage(attachment)) {
|
||||
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
|
||||
} else if (canPreviewPdf(attachment)) {
|
||||
attachmentPdfBlobUrl.value = await attachmentService.getBlobUrl(attachment)
|
||||
} else {
|
||||
downloadAttachment(attachment)
|
||||
}
|
||||
|
|
@ -576,6 +592,15 @@ defineExpose({
|
|||
block-size: 100%;
|
||||
}
|
||||
|
||||
.pdf-preview-iframe {
|
||||
inline-size: 100%;
|
||||
max-inline-size: calc(100% - 4rem);
|
||||
block-size: calc(100vh - var(--modal-content-spacing-tablet));
|
||||
border: none;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.is-task-cover {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
<template>
|
||||
<template v-if="kanbanView">
|
||||
<span class="has-text-grey-light"> > </span>
|
||||
<template v-if="canWrite">
|
||||
<Dropdown>
|
||||
<template #trigger="{toggleOpen}">
|
||||
<BaseButton
|
||||
class="bucket-name"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
{{ currentBucketTitle }}
|
||||
<Icon
|
||||
icon="pencil-alt"
|
||||
class="change-indicator"
|
||||
/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
<DropdownItem
|
||||
v-for="bucket in buckets"
|
||||
:key="bucket.id"
|
||||
:class="{'is-active': currentBucket?.id === bucket.id}"
|
||||
@click="changeBucket(bucket)"
|
||||
>
|
||||
{{ bucket.title }}
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="bucket-name"
|
||||
>
|
||||
{{ currentBucketTitle }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IBucket} from '@/modelTypes/IBucket'
|
||||
|
||||
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useKanbanStore} from '@/stores/kanban'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Dropdown from '@/components/misc/Dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
||||
|
||||
import BucketService from '@/services/bucket'
|
||||
import TaskBucketService from '@/services/taskBucket'
|
||||
import TaskBucketModel from '@/models/taskBucket'
|
||||
|
||||
import {success} from '@/message'
|
||||
|
||||
const props = defineProps<{
|
||||
task: ITask
|
||||
canWrite: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:task': [task: ITask]
|
||||
}>()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
const baseStore = useBaseStore()
|
||||
|
||||
const project = computed(() => projectStore.projects[props.task.projectId])
|
||||
|
||||
// If the project has exactly one manual kanban view, always use it.
|
||||
// If there are multiple, only show the selector when the active view is one of them.
|
||||
const kanbanView = computed(() => {
|
||||
if (!project.value?.views) {
|
||||
return null
|
||||
}
|
||||
|
||||
const manualKanbanViews = project.value.views.filter(
|
||||
v => v.viewKind === PROJECT_VIEW_KINDS.KANBAN
|
||||
&& v.bucketConfigurationMode === 'manual',
|
||||
)
|
||||
|
||||
if (manualKanbanViews.length === 1) {
|
||||
return manualKanbanViews[0]
|
||||
}
|
||||
|
||||
if (manualKanbanViews.length > 1) {
|
||||
const activeViewId = baseStore.currentProjectViewId
|
||||
return manualKanbanViews.find(v => v.id === activeViewId) || null
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const buckets = ref<IBucket[]>([])
|
||||
|
||||
watch(
|
||||
() => kanbanView.value,
|
||||
async (view) => {
|
||||
if (!view) {
|
||||
buckets.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const bucketService = new BucketService()
|
||||
try {
|
||||
buckets.value = await bucketService.getAll({
|
||||
projectId: props.task.projectId,
|
||||
projectViewId: view.id,
|
||||
} as IBucket)
|
||||
} catch (e) {
|
||||
console.error('Failed to load buckets:', e)
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const currentBucket = computed(() => {
|
||||
if (!kanbanView.value) {
|
||||
return undefined
|
||||
}
|
||||
return props.task.buckets?.find(b => b.projectViewId === kanbanView.value.id)
|
||||
})
|
||||
|
||||
const currentBucketTitle = computed(() => {
|
||||
return currentBucket.value?.title || t('task.detail.noBucket')
|
||||
})
|
||||
|
||||
async function changeBucket(bucket: IBucket) {
|
||||
if (!kanbanView.value || currentBucket.value?.id === bucket.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskBucketService = new TaskBucketService()
|
||||
const updatedTaskBucket = await taskBucketService.update(new TaskBucketModel({
|
||||
taskId: props.task.id,
|
||||
bucketId: bucket.id,
|
||||
projectViewId: kanbanView.value.id,
|
||||
projectId: props.task.projectId,
|
||||
}))
|
||||
|
||||
const updatedBuckets = (props.task.buckets || []).map(b => {
|
||||
if (b.projectViewId === kanbanView.value.id) {
|
||||
return {...bucket}
|
||||
}
|
||||
return b
|
||||
})
|
||||
|
||||
if (!updatedBuckets.find(b => b.projectViewId === kanbanView.value.id)) {
|
||||
updatedBuckets.push({...bucket})
|
||||
}
|
||||
|
||||
kanbanStore.moveTaskToBucket(props.task, bucket.id)
|
||||
|
||||
// Only pick up done state from the response since moving to/from the
|
||||
// done bucket can toggle it. Spreading the full response task would
|
||||
// overwrite fields like maxPermission that are not part of this endpoint.
|
||||
const updatedTask = {
|
||||
...props.task,
|
||||
done: updatedTaskBucket.task?.done ?? props.task.done,
|
||||
doneAt: updatedTaskBucket.task?.doneAt ?? props.task.doneAt,
|
||||
buckets: updatedBuckets,
|
||||
bucketId: bucket.id,
|
||||
}
|
||||
|
||||
emit('update:task', updatedTask)
|
||||
|
||||
success({message: t('task.detail.bucketChangedSuccess')})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bucket-name {
|
||||
color: var(--grey-800);
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.change-indicator {
|
||||
font-size: .75em;
|
||||
margin-inline-start: .25rem;
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
:deep(.dropdown) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,17 @@
|
|||
alt="Attachment preview"
|
||||
>
|
||||
|
||||
<!-- PDF icon -->
|
||||
<div
|
||||
v-else-if="isPdf"
|
||||
class="icon-wrapper"
|
||||
>
|
||||
<Icon
|
||||
size="6x"
|
||||
icon="file-pdf"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fallback -->
|
||||
<div
|
||||
v-else
|
||||
|
|
@ -19,10 +30,10 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, watchEffect} from 'vue'
|
||||
import {computed, ref, shallowReactive, watchEffect} from 'vue'
|
||||
import AttachmentService, {PREVIEW_SIZE} from '@/services/attachment'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
import {canPreview} from '@/models/attachment'
|
||||
import {canPreviewImage, canPreviewPdf} from '@/models/attachment'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: IAttachment
|
||||
|
|
@ -30,9 +41,10 @@ const props = defineProps<{
|
|||
|
||||
const attachmentService = shallowReactive(new AttachmentService())
|
||||
const blobUrl = ref<string | undefined>(undefined)
|
||||
const isPdf = computed(() => props.modelValue && canPreviewPdf(props.modelValue))
|
||||
|
||||
watchEffect(async () => {
|
||||
if (props.modelValue && canPreview(props.modelValue)) {
|
||||
if (props.modelValue && canPreviewImage(props.modelValue)) {
|
||||
blobUrl.value = await attachmentService.getBlobUrl(props.modelValue, PREVIEW_SIZE.MD)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,12 +12,16 @@
|
|||
@click="openTaskDetail"
|
||||
@keyup.enter="openTaskDetail"
|
||||
>
|
||||
<FancyCheckbox
|
||||
v-model="task.done"
|
||||
:disabled="(isArchived || disabled) && !canMarkAsDone"
|
||||
@update:modelValue="markAsDone"
|
||||
@click.stop
|
||||
/>
|
||||
<span
|
||||
v-tooltip="!canMarkAsDone ? $t('task.readOnlyCheckbox') : ''"
|
||||
>
|
||||
<FancyCheckbox
|
||||
v-model="task.done"
|
||||
:disabled="isArchived || disabled || !canMarkAsDone"
|
||||
@update:modelValue="markAsDone"
|
||||
@click.stop
|
||||
/>
|
||||
</span>
|
||||
|
||||
<ColorBubble
|
||||
v-if="!showProjectSeparately && projectColor !== '' && currentProject?.id !== task.projectId"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import {describe, it, expect, beforeEach} from 'vitest'
|
||||
import {setActivePinia, createPinia} from 'pinia'
|
||||
import {createI18n} from 'vue-i18n'
|
||||
import {defineComponent, h, ref, type Ref} from 'vue'
|
||||
import {mount} from '@vue/test-utils'
|
||||
|
||||
import {useDaytimeSalutation} from './useDaytimeSalutation'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {AUTH_TYPES} from '@/modelTypes/IUser'
|
||||
import en from '@/i18n/lang/en.json'
|
||||
|
||||
function makeDate(iso: string): Date {
|
||||
return new Date(iso)
|
||||
}
|
||||
|
||||
function makeI18n() {
|
||||
return createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: {en},
|
||||
})
|
||||
}
|
||||
|
||||
function runSalutation(now: Ref<Date>): string | undefined {
|
||||
let result: string | undefined
|
||||
const Comp = defineComponent({
|
||||
setup() {
|
||||
const s = useDaytimeSalutation(now)
|
||||
result = s.value
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
mount(Comp, {global: {plugins: [makeI18n()]}})
|
||||
return result
|
||||
}
|
||||
|
||||
function setUser() {
|
||||
const authStore = useAuthStore()
|
||||
authStore.setUser({
|
||||
id: 42,
|
||||
name: 'Ada',
|
||||
username: 'ada',
|
||||
type: AUTH_TYPES.LINK_SHARE,
|
||||
created: new Date('2024-01-15T10:00:00Z'),
|
||||
} as never, false)
|
||||
}
|
||||
|
||||
describe('useDaytimeSalutation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('returns undefined when the user has no display name', () => {
|
||||
const now = ref(makeDate('2026-04-06T09:00:00'))
|
||||
expect(runSalutation(now)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is deterministic for the same user, date, and bucket', () => {
|
||||
setUser()
|
||||
const now = ref(makeDate('2026-04-06T09:00:00'))
|
||||
const first = runSalutation(now)
|
||||
const second = runSalutation(now)
|
||||
|
||||
expect(first).toBeDefined()
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
|
||||
it('produces a string from the morning pool on a Monday morning', () => {
|
||||
setUser()
|
||||
const now = ref(makeDate('2026-04-06T09:00:00'))
|
||||
const result = runSalutation(now)
|
||||
|
||||
expect(result).toContain('Ada')
|
||||
const morningStrings = [
|
||||
'Good Morning Ada!',
|
||||
'Hey Ada, ready to go?',
|
||||
'Fresh start, Ada',
|
||||
'Coffee and tasks, Ada?',
|
||||
'Rise and plan, Ada',
|
||||
'Welcome back, Ada',
|
||||
'Fresh week, Ada',
|
||||
]
|
||||
expect(morningStrings).toContain(result)
|
||||
})
|
||||
|
||||
it('includes the Friday extra in the pool on Friday morning', () => {
|
||||
setUser()
|
||||
const reachable = new Set<string>()
|
||||
for (let day = 3; day <= 31; day += 7) {
|
||||
const iso = `2026-04-${String(day).padStart(2, '0')}T09:00:00`
|
||||
const r = runSalutation(ref(makeDate(iso)))
|
||||
if (r) reachable.add(r)
|
||||
}
|
||||
expect(reachable.size).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('uses different buckets for different hours', () => {
|
||||
setUser()
|
||||
const dateStr = '2026-04-06'
|
||||
const morning = runSalutation(ref(makeDate(`${dateStr}T09:00:00`)))
|
||||
const day = runSalutation(ref(makeDate(`${dateStr}T14:00:00`)))
|
||||
const evening = runSalutation(ref(makeDate(`${dateStr}T20:00:00`)))
|
||||
const night = runSalutation(ref(makeDate(`${dateStr}T02:00:00`)))
|
||||
|
||||
expect(morning).toBeDefined()
|
||||
expect(day).toBeDefined()
|
||||
expect(evening).toBeDefined()
|
||||
expect(night).toBeDefined()
|
||||
expect(new Set([morning, day, evening, night]).size).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('produces different results across consecutive days', () => {
|
||||
setUser()
|
||||
const results = new Set<string>()
|
||||
for (let day = 1; day <= 14; day++) {
|
||||
const iso = `2026-04-${String(day).padStart(2, '0')}T09:00:00`
|
||||
results.add(runSalutation(ref(makeDate(iso))) ?? '')
|
||||
}
|
||||
expect(results.size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,26 +1,95 @@
|
|||
import {computed, onActivated, ref} from 'vue'
|
||||
import {computed, onActivated, ref, type Ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {hourToDaytime} from '@/helpers/hourToDaytime'
|
||||
import {stringHash} from '@/helpers/stringHash'
|
||||
|
||||
export type Daytime = 'night' | 'morning' | 'day' | 'evening'
|
||||
|
||||
export function useDaytimeSalutation() {
|
||||
// Base i18n keys for each bucket. Existing keys (welcomeNight/Morning/Day/Evening)
|
||||
// are kept as the first entry of their respective pool so prior translations remain valid.
|
||||
const basePools: Record<Daytime, string[]> = {
|
||||
night: [
|
||||
'home.welcomeNight',
|
||||
'home.welcomeNightOwl',
|
||||
'home.welcomeNightBurning',
|
||||
'home.welcomeNightQuiet',
|
||||
'home.welcomeNightLate',
|
||||
'home.welcomeNightMoonlit',
|
||||
],
|
||||
morning: [
|
||||
'home.welcomeMorning',
|
||||
'home.welcomeMorningHey',
|
||||
'home.welcomeMorningFresh',
|
||||
'home.welcomeMorningCoffee',
|
||||
'home.welcomeMorningRise',
|
||||
'home.welcomeMorningBack',
|
||||
],
|
||||
day: [
|
||||
'home.welcomeDay',
|
||||
'home.welcomeDayBack',
|
||||
'home.welcomeDayFocus',
|
||||
'home.welcomeDayKeepGoing',
|
||||
'home.welcomeDayWhatsNext',
|
||||
'home.welcomeDayGood',
|
||||
],
|
||||
evening: [
|
||||
'home.welcomeEvening',
|
||||
'home.welcomeEveningWind',
|
||||
'home.welcomeEveningReturns',
|
||||
'home.welcomeEveningWrap',
|
||||
'home.welcomeEveningOneMore',
|
||||
'home.welcomeEveningStill',
|
||||
],
|
||||
}
|
||||
|
||||
// One entry per weekday (index = Date.getDay(), Sunday = 0). Appended to the
|
||||
// morning pool only, on its matching day.
|
||||
const morningWeekdayExtras: (string | null)[] = [
|
||||
'home.welcomeSundaySession', // 0 Sun
|
||||
'home.welcomeMondayFresh', // 1 Mon
|
||||
'home.welcomeTuesday', // 2 Tue
|
||||
'home.welcomeWednesdayMid', // 3 Wed
|
||||
'home.welcomeThursday', // 4 Thu
|
||||
'home.welcomeFridayPush', // 5 Fri
|
||||
'home.welcomeSaturday', // 6 Sat
|
||||
]
|
||||
|
||||
function poolFor(bucket: Daytime, now: Date): string[] {
|
||||
if (bucket !== 'morning') {
|
||||
return basePools[bucket]
|
||||
}
|
||||
const extra = morningWeekdayExtras[now.getDay()]
|
||||
return extra ? [...basePools.morning, extra] : basePools.morning
|
||||
}
|
||||
|
||||
function dateKey(now: Date): string {
|
||||
return `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`
|
||||
}
|
||||
|
||||
export function useDaytimeSalutation(now?: Ref<Date>) {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const now = ref(new Date())
|
||||
onActivated(() => now.value = new Date())
|
||||
const internalNow = ref(new Date())
|
||||
const currentDate = now ?? internalNow
|
||||
onActivated(() => {
|
||||
internalNow.value = new Date()
|
||||
})
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const name = computed(() => authStore.userDisplayName)
|
||||
const daytime = computed(() => hourToDaytime(now.value))
|
||||
// Use the user's created timestamp as the per-user hash component.
|
||||
// It's stable, unique per user, and doesn't leak the sequential user id.
|
||||
const userKey = computed(() => authStore.info?.created?.getTime() ?? 0)
|
||||
const bucket = computed(() => hourToDaytime(currentDate.value))
|
||||
|
||||
const salutations = {
|
||||
'night': () => t('home.welcomeNight', {username: name.value}),
|
||||
'morning': () => t('home.welcomeMorning', {username: name.value}),
|
||||
'day': () => t('home.welcomeDay', {username: name.value}),
|
||||
'evening': () => t('home.welcomeEvening', {username: name.value}),
|
||||
} as Record<Daytime, () => string>
|
||||
|
||||
return computed(() => name.value ? salutations[daytime.value]() : undefined)
|
||||
return computed(() => {
|
||||
if (!name.value) {
|
||||
return undefined
|
||||
}
|
||||
const pool = poolFor(bucket.value, currentDate.value)
|
||||
const key = `${dateKey(currentDate.value)}_${bucket.value}_${userKey.value}`
|
||||
const index = stringHash(key) % pool.length
|
||||
return t(pool[index], {username: name.value})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,12 +79,15 @@ export function useRouteWithModal() {
|
|||
|
||||
// Only navigate if we have a valid project and view
|
||||
if (baseStore.currentProject.id && viewId) {
|
||||
// Preserve query parameters (e.g., date range) from the backdrop view
|
||||
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
|
||||
const newRoute = {
|
||||
name: 'project.view',
|
||||
params: {
|
||||
projectId: baseStore.currentProject.id,
|
||||
viewId,
|
||||
},
|
||||
query: backdropRoute?.query || {},
|
||||
}
|
||||
|
||||
router.push(newRoute)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,32 @@ export interface SortBy {
|
|||
created?: Order
|
||||
updated?: Order
|
||||
done_at?: Order,
|
||||
position?: Order,
|
||||
}
|
||||
|
||||
const VALID_SORT_FIELDS = new Set<string>(
|
||||
['id', 'index', 'done', 'title', 'priority', 'due_date', 'start_date',
|
||||
'end_date', 'percent_done', 'created', 'updated', 'done_at', 'position'],
|
||||
)
|
||||
|
||||
function parseSortQuery(raw: string, fallback: SortBy): SortBy {
|
||||
const result: Record<string, Order> = {}
|
||||
for (const part of raw.split(',')) {
|
||||
const [field, order] = part.split(':')
|
||||
if (!VALID_SORT_FIELDS.has(field)) continue
|
||||
if (order !== 'asc' && order !== 'desc') continue
|
||||
result[field] = order
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result as SortBy : {...fallback}
|
||||
}
|
||||
|
||||
function serializeSortBy(sortBy: SortBy, defaultSort: SortBy): string | undefined {
|
||||
const keys = Object.keys(sortBy) as (keyof SortBy)[]
|
||||
const defaultKeys = Object.keys(defaultSort) as (keyof SortBy)[]
|
||||
const isDefault = keys.length === defaultKeys.length &&
|
||||
keys.every(k => sortBy[k] === defaultSort[k])
|
||||
if (isDefault) return undefined
|
||||
return keys.map(k => `${k}:${sortBy[k]}`).join(',')
|
||||
}
|
||||
|
||||
const SORT_BY_DEFAULT: SortBy = {
|
||||
|
|
@ -80,8 +106,19 @@ export function useTaskList(
|
|||
watch(() => params.value.filter, v => { filter.value = v || undefined })
|
||||
watch(() => params.value.s, v => { s.value = v || undefined })
|
||||
|
||||
const sortBy = ref({ ...sortByDefault })
|
||||
|
||||
const sortQuery = useRouteQuery('sort')
|
||||
|
||||
const sortBy = computed<SortBy>({
|
||||
get() {
|
||||
const raw = sortQuery.value as string | undefined
|
||||
if (!raw) return {...sortByDefault}
|
||||
return parseSortQuery(raw, sortByDefault)
|
||||
},
|
||||
set(val: SortBy) {
|
||||
sortQuery.value = serializeSortBy(val, sortByDefault) || undefined
|
||||
},
|
||||
})
|
||||
|
||||
const allParams = computed(() => {
|
||||
const loadParams = {...params.value}
|
||||
|
||||
|
|
|
|||
|
|
@ -177,4 +177,52 @@ describe('shouldShowTaskInListView', () => {
|
|||
|
||||
expect(shouldShowTaskInListView(subtask as ITask, allTasks)).toBe(false)
|
||||
})
|
||||
|
||||
it('should show subtasks in filtered views even when parent is in the same view', () => {
|
||||
const parentTask: Partial<ITask> = {
|
||||
id: 1,
|
||||
title: 'Parent Task',
|
||||
projectId: 100,
|
||||
relatedTasks: {},
|
||||
}
|
||||
|
||||
const subtask: Partial<ITask> = {
|
||||
id: 2,
|
||||
title: 'Subtask',
|
||||
projectId: 100,
|
||||
relatedTasks: {
|
||||
parenttask: [{
|
||||
id: 1,
|
||||
title: 'Parent Task',
|
||||
projectId: 100,
|
||||
} as ITask],
|
||||
},
|
||||
}
|
||||
|
||||
const allTasks = [parentTask, subtask] as ITask[]
|
||||
|
||||
// In a filtered view, both parent and subtask should be visible
|
||||
expect(shouldShowTaskInListView(parentTask as ITask, allTasks, true)).toBe(true)
|
||||
expect(shouldShowTaskInListView(subtask as ITask, allTasks, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('should show subtasks in filtered views even when only subtask matches filter', () => {
|
||||
const subtask: Partial<ITask> = {
|
||||
id: 2,
|
||||
title: 'Subtask matching filter',
|
||||
projectId: 100,
|
||||
relatedTasks: {
|
||||
parenttask: [{
|
||||
id: 1,
|
||||
title: 'Parent Task',
|
||||
projectId: 100,
|
||||
} as ITask],
|
||||
},
|
||||
}
|
||||
|
||||
// Only the subtask is in the results (parent didn't match filter)
|
||||
const allTasks = [subtask] as ITask[]
|
||||
|
||||
expect(shouldShowTaskInListView(subtask as ITask, allTasks, true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,14 +6,24 @@ import type {ITask} from '@/modelTypes/ITask'
|
|||
* Subtasks are hidden only when their parent task is also in the current view
|
||||
* (same project). Cross-project subtasks remain visible.
|
||||
*
|
||||
* In filtered views (saved filters), all tasks are shown regardless of parent
|
||||
* presence, since the user explicitly filtered for them.
|
||||
*
|
||||
* @param task - The task to check
|
||||
* @param allTasksInView - All tasks currently visible in the view
|
||||
* @param isFilteredView - Whether the current view is a saved/custom filter
|
||||
* @returns true if the task should be shown, false if it should be hidden
|
||||
*/
|
||||
export function shouldShowTaskInListView(
|
||||
task: ITask,
|
||||
allTasksInView: ITask[],
|
||||
isFilteredView: boolean = false,
|
||||
): boolean {
|
||||
// In filtered views (saved filters), show all tasks that matched the filter
|
||||
if (isFilteredView) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If task has no parent, always show it
|
||||
const parentTasksCount = task.relatedTasks?.parenttask?.length ?? 0
|
||||
if (parentTasksCount === 0) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
import {ref, readonly} from 'vue'
|
||||
|
||||
import {getToken} from '@/helpers/auth'
|
||||
|
||||
type MessageCallback = (msg: WebSocketEvent) => void
|
||||
|
||||
interface WebSocketEvent {
|
||||
event?: string
|
||||
action?: string
|
||||
success?: boolean
|
||||
error?: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
const RECONNECT_BASE_DELAY = 1000
|
||||
const RECONNECT_MAX_DELAY = 30000
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
let reconnectAttempt = 0
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const subscriptions = new Map<string, Set<MessageCallback>>()
|
||||
const connected = ref(false)
|
||||
const authenticated = ref(false)
|
||||
let manuallyDisconnected = false
|
||||
|
||||
function getWebSocketUrl(): string {
|
||||
const base = window.API_URL.replace(/\/+$/, '')
|
||||
const wsProtocol = base.startsWith('https') ? 'wss' : 'ws'
|
||||
return base.replace(/^https?/, wsProtocol) + '/ws'
|
||||
}
|
||||
|
||||
function sendMessage(msg: object) {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
function sendAuth() {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
sendMessage({action: 'auth', token})
|
||||
}
|
||||
}
|
||||
|
||||
function resubscribeAll() {
|
||||
for (const event of subscriptions.keys()) {
|
||||
sendMessage({action: 'subscribe', event})
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
let msg: WebSocketEvent
|
||||
try {
|
||||
msg = JSON.parse(event.data)
|
||||
} catch {
|
||||
console.warn('WebSocket: invalid message', event.data)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle auth success
|
||||
if (msg.action === 'auth.success' && msg.success) {
|
||||
authenticated.value = true
|
||||
console.debug('WebSocket: authenticated')
|
||||
resubscribeAll()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle auth error - treat as terminal (no reconnect) so we don't
|
||||
// thrash the WS endpoint with a bad token. Fallback polling kicks in.
|
||||
if (msg.error === 'invalid_token' || msg.error === 'auth_required') {
|
||||
console.warn('WebSocket: auth failed:', msg.error)
|
||||
manuallyDisconnected = true
|
||||
authenticated.value = false
|
||||
connected.value = false
|
||||
socket?.close()
|
||||
socket = null
|
||||
return
|
||||
}
|
||||
|
||||
// Handle regular events — route by event name
|
||||
if (msg.event) {
|
||||
const callbacks = subscriptions.get(msg.event)
|
||||
if (callbacks) {
|
||||
for (const cb of callbacks) {
|
||||
cb(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (manuallyDisconnected) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
const baseDelay = Math.min(
|
||||
RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempt),
|
||||
RECONNECT_MAX_DELAY,
|
||||
)
|
||||
// Add ±25% jitter to prevent thundering herd on server restart
|
||||
const jitter = baseDelay * (0.75 + Math.random() * 0.5)
|
||||
const delay = Math.round(jitter)
|
||||
reconnectAttempt++
|
||||
console.debug(`WebSocket: reconnecting in ${delay}ms (attempt ${reconnectAttempt})`)
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
manuallyDisconnected = false
|
||||
authenticated.value = false
|
||||
const url = getWebSocketUrl()
|
||||
|
||||
try {
|
||||
socket = new WebSocket(url)
|
||||
} catch (e) {
|
||||
console.warn('WebSocket: failed to create connection', e)
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
connected.value = true
|
||||
reconnectAttempt = 0
|
||||
console.debug('WebSocket: connected, sending auth')
|
||||
sendAuth()
|
||||
}
|
||||
|
||||
socket.onmessage = handleMessage
|
||||
|
||||
socket.onclose = () => {
|
||||
connected.value = false
|
||||
authenticated.value = false
|
||||
socket = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
// onclose will fire after onerror, which handles reconnect
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
manuallyDisconnected = true
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
if (socket) {
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
connected.value = false
|
||||
authenticated.value = false
|
||||
subscriptions.clear()
|
||||
}
|
||||
|
||||
function subscribe(event: string, callback: MessageCallback): () => void {
|
||||
if (!subscriptions.has(event)) {
|
||||
subscriptions.set(event, new Set())
|
||||
}
|
||||
subscriptions.get(event)!.add(callback)
|
||||
|
||||
// Only send subscribe if already authenticated
|
||||
// (otherwise it will be sent after auth succeeds)
|
||||
if (authenticated.value) {
|
||||
sendMessage({action: 'subscribe', event})
|
||||
}
|
||||
|
||||
return () => {
|
||||
const callbacks = subscriptions.get(event)
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback)
|
||||
if (callbacks.size === 0) {
|
||||
subscriptions.delete(event)
|
||||
sendMessage({action: 'unsubscribe', event})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
connected: readonly(connected),
|
||||
authenticated: readonly(authenticated),
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,17 @@ import type {AxiosRequestConfig} from 'axios'
|
|||
import {getToken, refreshToken} from '@/helpers/auth'
|
||||
import {AUTH_TYPES} from '@/modelTypes/IUser'
|
||||
|
||||
/**
|
||||
* Returns the API base URL with a guaranteed trailing slash.
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
const url = window.API_URL
|
||||
return url?.endsWith('/') ? url : url + '/'
|
||||
}
|
||||
|
||||
export function HTTPFactory() {
|
||||
const instance = axios.create({
|
||||
baseURL: window.API_URL,
|
||||
baseURL: getApiBaseUrl(),
|
||||
// Ensure the browser sends and accepts cookies (e.g. the HttpOnly
|
||||
// refresh token) even when the API is on a different origin.
|
||||
withCredentials: true,
|
||||
|
|
@ -14,7 +22,7 @@ export function HTTPFactory() {
|
|||
instance.interceptors.request.use((config) => {
|
||||
// by setting the baseURL fresh for every request
|
||||
// we make sure that it is never outdated in case it is updated
|
||||
config.baseURL = window.API_URL
|
||||
config.baseURL = getApiBaseUrl()
|
||||
|
||||
return config
|
||||
})
|
||||
|
|
@ -30,11 +38,20 @@ async function doRefresh(): Promise<string | null> {
|
|||
try {
|
||||
await refreshToken(true)
|
||||
return getToken()
|
||||
} catch {
|
||||
// Refresh failed. Don't remove the token here — in a multi-tab scenario,
|
||||
// another tab may have successfully rotated the refresh token, and clearing
|
||||
// localStorage would log out that tab too. Let the caller decide.
|
||||
return null
|
||||
} catch (_e) {
|
||||
// Single retry after a short delay for transient failures (network
|
||||
// blip, server restart). If this also fails, give up.
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
await refreshToken(true)
|
||||
return getToken()
|
||||
} catch (retryErr) {
|
||||
// Refresh failed. Don't remove the token here — in a multi-tab scenario,
|
||||
// another tab may have successfully rotated the refresh token, and clearing
|
||||
// localStorage would log out that tab too. Let the caller decide.
|
||||
console.warn('[Vikunja] Token refresh failed:', retryErr)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {stringHash} from './stringHash'
|
||||
|
||||
describe('stringHash', () => {
|
||||
it('returns a non-negative integer', () => {
|
||||
expect(stringHash('hello')).toBeGreaterThanOrEqual(0)
|
||||
expect(Number.isInteger(stringHash('hello'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is deterministic for the same input', () => {
|
||||
expect(stringHash('foo')).toBe(stringHash('foo'))
|
||||
})
|
||||
|
||||
it('returns different values for different inputs', () => {
|
||||
expect(stringHash('foo')).not.toBe(stringHash('bar'))
|
||||
})
|
||||
|
||||
it('handles the empty string', () => {
|
||||
expect(stringHash('')).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Deterministic non-cryptographic string hash (djb2 variant).
|
||||
// Used for stable pseudo-random selection keyed on date + user + bucket.
|
||||
export function stringHash(input: string): number {
|
||||
// 5381 is the canonical djb2 seed — a prime that empirically yields a good
|
||||
// distribution when combined with the `hash * 33 + c` step below.
|
||||
let hash = 5381
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
// hash * 33 + char, kept in 32-bit range via `| 0`.
|
||||
hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0
|
||||
}
|
||||
// Ensure non-negative.
|
||||
return hash >>> 0
|
||||
}
|
||||
|
|
@ -5,9 +5,36 @@
|
|||
},
|
||||
"home": {
|
||||
"welcomeNight": "Gute Nacht, {username}!",
|
||||
"welcomeNightOwl": "Hallo, Nacht-Eule {username}",
|
||||
"welcomeNightBurning": "Machst du mal wieder die Nacht zum Tag, {username}?",
|
||||
"welcomeNightQuiet": "Ruhezeit, {username}",
|
||||
"welcomeNightLate": "Es ist spät, {username}",
|
||||
"welcomeNightMoonlit": "Mondlicht-Planung, {username}?",
|
||||
"welcomeMorning": "Guten Morgen, {username}!",
|
||||
"welcomeMorningHey": "Hey {username}, los geht's?",
|
||||
"welcomeMorningFresh": "Frisch in den Tag, {username}",
|
||||
"welcomeMorningCoffee": "Kaffee und Aufgaben, {username}?",
|
||||
"welcomeMorningRise": "Morgenplan hat Gold im Mund, {username}",
|
||||
"welcomeMorningBack": "Willkommen zurück, {username}",
|
||||
"welcomeMondayFresh": "Frische Woche, {username}",
|
||||
"welcomeTuesday": "Fröhlichen Dienstag, {username}",
|
||||
"welcomeWednesdayMid": "Bergfest, {username}",
|
||||
"welcomeThursday": "Fast geschafft, {username}",
|
||||
"welcomeFridayPush": "Endspurt ins Wochenende, {username}?",
|
||||
"welcomeSaturday": "Wochenendmodus, {username}",
|
||||
"welcomeSundaySession": "Sonntagsschicht, {username}?",
|
||||
"welcomeDay": "Hallo {username}!",
|
||||
"welcomeDayBack": "Wieder zurück, {username}",
|
||||
"welcomeDayFocus": "Fokus, {username}",
|
||||
"welcomeDayKeepGoing": "Weiter geht's, {username}",
|
||||
"welcomeDayWhatsNext": "Was kommt als Nächstes, {username}?",
|
||||
"welcomeDayGood": "Guten Nachmittag, {username}",
|
||||
"welcomeEvening": "Guten Abend, {username}!",
|
||||
"welcomeEveningWind": "Feierabend, {username}?",
|
||||
"welcomeEveningReturns": "{username} kehrt zurück",
|
||||
"welcomeEveningWrap": "Feierabend in Sicht, {username}?",
|
||||
"welcomeEveningOneMore": "Noch eine Sache, {username}?",
|
||||
"welcomeEveningStill": "Immer noch da, {username}?",
|
||||
"lastViewed": "Zuletzt angesehen",
|
||||
"addToHomeScreen": "Füge diese App deinem Startbildschirm hinzu, um schneller darauf zuzugreifen und das Erlebnis zu verbessern.",
|
||||
"goToOverview": "Zur Übersicht",
|
||||
|
|
@ -135,7 +162,13 @@
|
|||
"taskAndNotifications": "Projekte & Aufgaben",
|
||||
"privacy": "Privatsphäre",
|
||||
"localization": "Sprachauswahl",
|
||||
"appearance": "Aussehen & Verhalten"
|
||||
"appearance": "Aussehen & Verhalten",
|
||||
"desktop": "Desktop-App"
|
||||
},
|
||||
"desktop": {
|
||||
"quickEntryShortcut": "Quick Entry Tastenkombination",
|
||||
"shortcutRecorderPlaceholder": "Klicken, um Tastenkombination zu setzen",
|
||||
"shortcutRecorderRecording": "Tastenkombination eingeben…"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Zwei-Faktor-Authentifizierung",
|
||||
|
|
@ -843,6 +876,7 @@
|
|||
"addReminder": "Eine Erinnerung hinzufügen…",
|
||||
"doneSuccess": "Die Aufgabe wurde erfolgreich als erledigt markiert.",
|
||||
"undoneSuccess": "Die Aufgabe wurde erfolgreich als nicht-erledigt markiert.",
|
||||
"readOnlyCheckbox": "Du hast nur Lesezugriff auf diese Aufgabe und kannst sie nicht als erledigt markieren.",
|
||||
"movedToProject": "Die Aufgabe wurde nach {project} verschoben.",
|
||||
"undo": "Rückgängig",
|
||||
"openDetail": "Aufgabe in der Detailansicht anzeigen",
|
||||
|
|
@ -873,6 +907,8 @@
|
|||
"updateSuccess": "Die Aufgabe wurde erfolgreich gespeichert.",
|
||||
"deleteSuccess": "Die Aufgabe wurde erfolgreich gelöscht.",
|
||||
"duplicateSuccess": "Die Aufgabe wurde erfolgreich dupliziert.",
|
||||
"noBucket": "Keine Spalte",
|
||||
"bucketChangedSuccess": "Die Spalte der Aufgabe wurde erfolgreich geändert.",
|
||||
"belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“",
|
||||
"back": "Zurück zum Projekt",
|
||||
"due": "Fällig {at}",
|
||||
|
|
@ -1067,6 +1103,7 @@
|
|||
},
|
||||
"quickAddMagic": {
|
||||
"hint": "Verwende magische Präfixe, um Fälligkeitsdaten, Zuweisungen und andere Aufgabeneigenschaften zu definieren.",
|
||||
"quickEntryHint": "Verwende magische Präfixe für Datum, Labels & mehr. Öffne die Hauptanwendung von Vikunja und überprüfe den Tooltip bei der Aufgabeneingabe für weitere Details.",
|
||||
"title": "Quick Add Magic",
|
||||
"intro": "Beim Erstellen einer Aufgabe kannst du spezielle Schlüsselwörter verwenden, um Attribute direkt zu der neu erstellten Aufgabe hinzuzufügen. Dadurch können häufig verwendete Attribute schneller zu Aufgaben hinzugefügt werden.",
|
||||
"multiple": "Du kannst das mehrmals benutzen.",
|
||||
|
|
@ -1243,6 +1280,7 @@
|
|||
"markAllReadSuccess": "Alle Benachrichtigungen erfolgreich als gelesen markiert."
|
||||
},
|
||||
"quickActions": {
|
||||
"notLoggedIn": "Bitte melde dich zuerst im Hauptfenster von Vikunja an.",
|
||||
"commands": "Befehle",
|
||||
"placeholder": "Gib einen Befehl oder eine Suche ein …",
|
||||
"hint": "Du kannst {project} verwenden, um die Suche auf ein Projekt zu beschränken. Kombiniere {project} oder {label} (Labels) mit einer Suchabfrage, um eine Aufgabe mit diesen Labels oder auf diesem Projekt zu suchen. Verwende {assignee}, um nur nach Teams zu suchen.",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,36 @@
|
|||
},
|
||||
"home": {
|
||||
"welcomeNight": "Gute Nacht, {username}!",
|
||||
"welcomeNightOwl": "Hallo, Nacht-Eule {username}",
|
||||
"welcomeNightBurning": "Machst du mal wieder die Nacht zum Tag, {username}?",
|
||||
"welcomeNightQuiet": "Ruhezeit, {username}",
|
||||
"welcomeNightLate": "Es ist spät, {username}",
|
||||
"welcomeNightMoonlit": "Mondlicht-Planung, {username}?",
|
||||
"welcomeMorning": "Guten Morgen, {username}!",
|
||||
"welcomeMorningHey": "Hey {username}, los geht's?",
|
||||
"welcomeMorningFresh": "Frisch in den Tag, {username}",
|
||||
"welcomeMorningCoffee": "Kaffee und Aufgaben, {username}?",
|
||||
"welcomeMorningRise": "Morgenplan hat Gold im Mund, {username}",
|
||||
"welcomeMorningBack": "Willkommen zurück, {username}",
|
||||
"welcomeMondayFresh": "Frische Woche, {username}",
|
||||
"welcomeTuesday": "Fröhlichen Dienstag, {username}",
|
||||
"welcomeWednesdayMid": "Bergfest, {username}",
|
||||
"welcomeThursday": "Fast geschafft, {username}",
|
||||
"welcomeFridayPush": "Endspurt ins Wochenende, {username}?",
|
||||
"welcomeSaturday": "Wochenendmodus, {username}",
|
||||
"welcomeSundaySession": "Sonntagsschicht, {username}?",
|
||||
"welcomeDay": "Hallo {username}!",
|
||||
"welcomeDayBack": "Wieder zurück, {username}",
|
||||
"welcomeDayFocus": "Fokus, {username}",
|
||||
"welcomeDayKeepGoing": "Weiter geht's, {username}",
|
||||
"welcomeDayWhatsNext": "Was kommt als Nächstes, {username}?",
|
||||
"welcomeDayGood": "Guten Nachmittag, {username}",
|
||||
"welcomeEvening": "Guten Abend, {username}!",
|
||||
"welcomeEveningWind": "Feierabend, {username}?",
|
||||
"welcomeEveningReturns": "{username} kehrt zurück",
|
||||
"welcomeEveningWrap": "Feierabend in Sicht, {username}?",
|
||||
"welcomeEveningOneMore": "Noch eine Sache, {username}?",
|
||||
"welcomeEveningStill": "Immer noch da, {username}?",
|
||||
"lastViewed": "Zletscht ahglueget",
|
||||
"addToHomeScreen": "Füge diese App deinem Startbildschirm hinzu, um schneller darauf zuzugreifen und das Erlebnis zu verbessern.",
|
||||
"goToOverview": "Zur Übersicht",
|
||||
|
|
@ -135,7 +162,13 @@
|
|||
"taskAndNotifications": "Projekte & Aufgaben",
|
||||
"privacy": "Privatsphäre",
|
||||
"localization": "Sprachauswahl",
|
||||
"appearance": "Aussehen & Verhalten"
|
||||
"appearance": "Aussehen & Verhalten",
|
||||
"desktop": "Desktop-App"
|
||||
},
|
||||
"desktop": {
|
||||
"quickEntryShortcut": "Quick Entry Tastenkombination",
|
||||
"shortcutRecorderPlaceholder": "Klicken, um Tastenkombination zu setzen",
|
||||
"shortcutRecorderRecording": "Tastenkombination eingeben…"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Zweifaktor Authentifizierig",
|
||||
|
|
@ -843,6 +876,7 @@
|
|||
"addReminder": "Eine Erinnerung hinzufügen…",
|
||||
"doneSuccess": "Die Uufgab isch erfolgriich als \"Fertig\" markiert wordä.",
|
||||
"undoneSuccess": "Die Uufgaab isch nüme als fertig markiert.",
|
||||
"readOnlyCheckbox": "Du hast nur Lesezugriff auf diese Aufgabe und kannst sie nicht als erledigt markieren.",
|
||||
"movedToProject": "Die Aufgabe wurde nach {project} verschoben.",
|
||||
"undo": "Rückgängig",
|
||||
"openDetail": "Uufgab i de Detailaahsicht öffne",
|
||||
|
|
@ -873,6 +907,8 @@
|
|||
"updateSuccess": "Die Uufgab isch erfolgriich g'speichered wore.",
|
||||
"deleteSuccess": "Die Uufgab isch erfolgriich g'chüblet wore.",
|
||||
"duplicateSuccess": "Die Aufgabe wurde erfolgreich dupliziert.",
|
||||
"noBucket": "Keine Spalte",
|
||||
"bucketChangedSuccess": "Die Spalte der Aufgabe wurde erfolgreich geändert.",
|
||||
"belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“",
|
||||
"back": "Zurück zum Projekt",
|
||||
"due": "Fällig bis {at}",
|
||||
|
|
@ -1067,6 +1103,7 @@
|
|||
},
|
||||
"quickAddMagic": {
|
||||
"hint": "Verwende magische Präfixe, um Fälligkeitsdaten, Zuweisungen und andere Aufgabeneigenschaften zu definieren.",
|
||||
"quickEntryHint": "Verwende magische Präfixe für Datum, Labels & mehr. Öffne die Hauptanwendung von Vikunja und überprüfe den Tooltip bei der Aufgabeneingabe für weitere Details.",
|
||||
"title": "Quick Add Magic",
|
||||
"intro": "Bim erstelle vonere Uufgab, chasch du spezielli Schlüsselwörter verwende, umm Attribute direkt zu dere Uufgab hinzuezfüege. Das Erlaubts, um pblichi Attribute schneller zu Uufgabe hinzuezfüege.",
|
||||
"multiple": "Du chasch da mehrmals mache.",
|
||||
|
|
@ -1243,6 +1280,7 @@
|
|||
"markAllReadSuccess": "Alle Benachrichtigungen erfolgreich als gelesen markiert."
|
||||
},
|
||||
"quickActions": {
|
||||
"notLoggedIn": "Bitte melde dich zuerst im Hauptfenster von Vikunja an.",
|
||||
"commands": "Befehl",
|
||||
"placeholder": "Schriib en Befehl oder suech…",
|
||||
"hint": "Du kannst {project} verwenden, um die Suche auf ein Projekt zu beschränken. Kombiniere {project} oder {label} (Labels) mit einer Suchabfrage, um eine Aufgabe mit diesen Labels oder auf diesem Projekt zu suchen. Verwende {assignee}, um nur nach Teams zu suchen.",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,36 @@
|
|||
{
|
||||
"home": {
|
||||
"welcomeNight": "Good Night {username}!",
|
||||
"welcomeNightOwl": "Hello, night owl {username}",
|
||||
"welcomeNightBurning": "Burning the midnight oil, {username}?",
|
||||
"welcomeNightQuiet": "Quiet hours, {username}",
|
||||
"welcomeNightLate": "It's late, {username}",
|
||||
"welcomeNightMoonlit": "Moonlit planning, {username}?",
|
||||
"welcomeMorning": "Good Morning {username}!",
|
||||
"welcomeMorningHey": "Hey {username}, ready to go?",
|
||||
"welcomeMorningFresh": "Fresh start, {username}",
|
||||
"welcomeMorningCoffee": "Coffee and tasks, {username}?",
|
||||
"welcomeMorningRise": "Rise and plan, {username}",
|
||||
"welcomeMorningBack": "Welcome back, {username}",
|
||||
"welcomeMondayFresh": "Fresh week, {username}",
|
||||
"welcomeTuesday": "Happy Tuesday, {username}",
|
||||
"welcomeWednesdayMid": "Midweek already, {username}",
|
||||
"welcomeThursday": "Almost there, {username}",
|
||||
"welcomeFridayPush": "Friday push, {username}?",
|
||||
"welcomeSaturday": "Weekend mode, {username}",
|
||||
"welcomeSundaySession": "Sunday session, {username}?",
|
||||
"welcomeDay": "Hi {username}!",
|
||||
"welcomeDayBack": "Back at it, {username}",
|
||||
"welcomeDayFocus": "Let's focus, {username}",
|
||||
"welcomeDayKeepGoing": "Keep going, {username}",
|
||||
"welcomeDayWhatsNext": "What's next, {username}?",
|
||||
"welcomeDayGood": "Good afternoon, {username}",
|
||||
"welcomeEvening": "Good Evening {username}!",
|
||||
"welcomeEveningWind": "Winding down, {username}?",
|
||||
"welcomeEveningReturns": "{username} returns",
|
||||
"welcomeEveningWrap": "Time to wrap up, {username}?",
|
||||
"welcomeEveningOneMore": "One more thing, {username}?",
|
||||
"welcomeEveningStill": "Still at it, {username}?",
|
||||
"lastViewed": "Last viewed",
|
||||
"addToHomeScreen": "Add this app to your home screen for faster access and improved experience.",
|
||||
"goToOverview": "Go to overview",
|
||||
|
|
@ -112,6 +139,7 @@
|
|||
"timezone": "Time zone",
|
||||
"overdueTasksRemindersTime": "Overdue tasks reminder email time",
|
||||
"filterUsedOnOverview": "Saved filter used on the overview page",
|
||||
"showLastViewed": "Show last viewed projects on the overview page",
|
||||
"minimumPriority": "Minimum visible task priority",
|
||||
"dateDisplay": "Date display format",
|
||||
"dateDisplayOptions": {
|
||||
|
|
@ -414,7 +442,8 @@
|
|||
"addPlaceholder": "Add a task…",
|
||||
"empty": "This project is currently empty.",
|
||||
"newTaskCta": "Create a task.",
|
||||
"editTask": "Edit Task"
|
||||
"editTask": "Edit Task",
|
||||
"sort": "Sort"
|
||||
},
|
||||
"gantt": {
|
||||
"title": "Gantt",
|
||||
|
|
@ -606,6 +635,29 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sorting": {
|
||||
"manually": "Manually",
|
||||
"apply": "Apply sort",
|
||||
"description": "Choose how tasks in this list are sorted. When sorting manually, you can drag and drop tasks to reorder them.",
|
||||
"options": {
|
||||
"titleAsc": "Title (A–Z)",
|
||||
"titleDesc": "Title (Z–A)",
|
||||
"priorityDesc": "Priority (Highest first)",
|
||||
"priorityAsc": "Priority (Lowest first)",
|
||||
"dueDateAsc": "Due date (Earliest first)",
|
||||
"dueDateDesc": "Due date (Latest first)",
|
||||
"startDateAsc": "Start date (Earliest first)",
|
||||
"startDateDesc": "Start date (Latest first)",
|
||||
"endDateAsc": "End date (Earliest first)",
|
||||
"endDateDesc": "End date (Latest first)",
|
||||
"percentDoneDesc": "% done (Most done first)",
|
||||
"percentDoneAsc": "% done (Least done first)",
|
||||
"createdDesc": "Created (Newest first)",
|
||||
"createdAsc": "Created (Oldest first)",
|
||||
"updatedDesc": "Updated (Newest first)",
|
||||
"updatedAsc": "Updated (Oldest first)"
|
||||
}
|
||||
},
|
||||
"migrate": {
|
||||
"title": "Import from other services",
|
||||
"titleService": "Import your data from {name} into Vikunja",
|
||||
|
|
@ -621,7 +673,33 @@
|
|||
"importUpload": "To import data from {name} into Vikunja, click the button below to select a file.",
|
||||
"upload": "Upload file",
|
||||
"migrationStartedWillReciveEmail": "Vikunja will now import your lists/projects, tasks, notes, reminders and files from {service}. As this will take a while, we will send you an email once done. You can close this window now.",
|
||||
"migrationInProgress": "A migration is currently in progress. Please wait until it is done."
|
||||
"migrationInProgress": "A migration is currently in progress. Please wait until it is done.",
|
||||
"csv": {
|
||||
"description": "Import tasks from a CSV file with custom column mapping.",
|
||||
"uploadDescription": "Select a CSV file to import. The file should contain task data with headers in the first row.",
|
||||
"selectFile": "Select CSV file",
|
||||
"columnMapping": "Column Mapping",
|
||||
"columnMappingDescription": "Map each column in your CSV file to a task attribute. Vikunja has auto-detected the most likely mappings. The preview below will update automatically when you change settings.",
|
||||
"parsingOptions": "Parsing Options",
|
||||
"delimiter": "Delimiter",
|
||||
"dateFormat": "Date Format",
|
||||
"skipRows": "Skip Rows",
|
||||
"mapColumns": "Map Columns",
|
||||
"example": "e.g.",
|
||||
"preview": "Preview",
|
||||
"previewDescription": "Showing first 5 of {count} tasks that will be imported.",
|
||||
"previewErrors": "{count} rows had parsing errors and will be skipped.",
|
||||
"import": "Import Tasks",
|
||||
"untitled": "Untitled Task",
|
||||
"completed": "Completed",
|
||||
"ignore": "Ignore",
|
||||
"delimiters": {
|
||||
"comma": "Comma (,)",
|
||||
"semicolon": "Semicolon (;)",
|
||||
"tab": "Tab",
|
||||
"pipe": "Pipe (|)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"title": "Labels",
|
||||
|
|
@ -856,6 +934,7 @@
|
|||
"addReminder": "Add a reminder…",
|
||||
"doneSuccess": "The task was successfully marked as done.",
|
||||
"undoneSuccess": "The task was successfully un-marked as done.",
|
||||
"readOnlyCheckbox": "You only have read access to this task and cannot mark it as done.",
|
||||
"movedToProject": "The task was moved to {project}.",
|
||||
"undo": "Undo",
|
||||
"openDetail": "Open task detail view",
|
||||
|
|
@ -886,6 +965,8 @@
|
|||
"updateSuccess": "The task was saved successfully.",
|
||||
"deleteSuccess": "The task has been deleted successfully.",
|
||||
"duplicateSuccess": "The task was duplicated successfully.",
|
||||
"noBucket": "No bucket",
|
||||
"bucketChangedSuccess": "The task bucket has been changed successfully.",
|
||||
"belongsToProject": "This task belongs to project '{project}'",
|
||||
"back": "Back to project",
|
||||
"due": "Due {at}",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@
|
|||
"authenticating": "Todennetaan…",
|
||||
"openIdStateError": "Tila ei täsmää, kieltäydytään jatkamasta!",
|
||||
"openIdGeneralError": "Tapahtui virhe todennettaessa kolmatta osapuolta vastaan.",
|
||||
"desktopTryDemo": "Kokeile Demoa",
|
||||
"desktopWaitingForAuth": "Odotetaan autentikointia…",
|
||||
"desktopOAuthError": "Autentikointi epäonnistui: {error}",
|
||||
"logout": "Kirjaudu ulos",
|
||||
"emailInvalid": "Ole hyvä ja syötä kelvollinen sähköpostiosoite.",
|
||||
"usernameRequired": "Ole hyvä ja anna käyttäjätunnus.",
|
||||
|
|
@ -199,7 +202,9 @@
|
|||
},
|
||||
"sessions": {
|
||||
"deviceInfo": "Laite",
|
||||
"ipAddress": "IP-Osoite"
|
||||
"ipAddress": "IP-Osoite",
|
||||
"lastActive": "Viimeksi Aktiivinen",
|
||||
"noOtherSessions": "Ei muita aktiivisia sessioita."
|
||||
}
|
||||
},
|
||||
"deletion": {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@
|
|||
"authenticating": "Аутентификация…",
|
||||
"openIdStateError": "Состояние не совпадает, поэтому не продолжаем!",
|
||||
"openIdGeneralError": "Произошла ошибка при аутентификации через сторонний сервис.",
|
||||
"oauthMissingParams": "Отсутствуют необходимые параметры OAuth: {params}",
|
||||
"oauthRedirectedToApp": "Вы были перенаправлены в приложение. Теперь вы можете закрыть эту вкладку.",
|
||||
"logout": "Выйти",
|
||||
"emailInvalid": "Введите корректный email адрес.",
|
||||
"usernameRequired": "Введите имя пользователя.",
|
||||
|
|
@ -155,7 +157,8 @@
|
|||
"tokenCreated": "Ваш новый токен: {token}",
|
||||
"wontSeeItAgain": "Запишите его где-нибудь — у вас больше не будет возможности его увидеть.",
|
||||
"mustUseToken": "Вам необходимо создать токен CalDAV, если вы хотите использовать его со сторонним клиентом. Используйте его в качестве пароля.",
|
||||
"usernameIs": "Имя пользователя для CalDAV: {0}"
|
||||
"usernameIs": "Имя пользователя для CalDAV: {0}",
|
||||
"apiTokenHint": "Вы также можете использовать токен API с разрешением CalDAV. Создайте его в {link}."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Аватар",
|
||||
|
|
@ -863,6 +866,8 @@
|
|||
"updateSuccess": "Задача сохранена.",
|
||||
"deleteSuccess": "Задача удалена.",
|
||||
"duplicateSuccess": "Задача продублирована.",
|
||||
"noBucket": "Нет колонки",
|
||||
"bucketChangedSuccess": "Колонка задачи была успешно изменена.",
|
||||
"belongsToProject": "Задача принадлежит проекту «{project}»",
|
||||
"back": "Вернуться к проекту",
|
||||
"due": "Истекает {at}",
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import {Document} from 'flexsearch'
|
||||
|
||||
export interface withId {
|
||||
id: number,
|
||||
}
|
||||
|
||||
const indexes: { [k: string]: Document<withId> } = {}
|
||||
|
||||
export const createNewIndexer = (name: string, fieldsToIndex: string[]) => {
|
||||
if (typeof indexes[name] === 'undefined') {
|
||||
indexes[name] = new Document<withId>({
|
||||
tokenize: 'full',
|
||||
document: {
|
||||
id: 'id',
|
||||
index: fieldsToIndex,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const index = indexes[name]
|
||||
|
||||
function add(item: withId) {
|
||||
return index.add(item.id, item)
|
||||
}
|
||||
|
||||
function remove(item: withId) {
|
||||
return index.remove(item.id)
|
||||
}
|
||||
|
||||
function update(item: withId) {
|
||||
return index.update(item.id, item)
|
||||
}
|
||||
|
||||
function search(query: string | null) {
|
||||
if (query === '' || query === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return index.search(query)
|
||||
?.flatMap(r => r.result)
|
||||
.filter((value, index, self) => self.indexOf(value) === index) as number[]
|
||||
|| null
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
remove,
|
||||
update,
|
||||
search,
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ export interface ITask extends IAbstract {
|
|||
|
||||
projectId: IProject['id'] // Meta, only used when creating a new task
|
||||
bucketId: IBucket['id']
|
||||
buckets: IBucket[]
|
||||
}
|
||||
|
||||
export type ITaskPartialWithId = PartialWithId<ITask>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface IFrontendSettings {
|
|||
defaultTaskRelationType: IRelationKind
|
||||
backgroundBrightness: number | null
|
||||
alwaysShowBucketTaskCount: boolean
|
||||
showLastViewed: boolean
|
||||
sidebarWidth: number | null
|
||||
commentSortOrder: 'asc' | 'desc'
|
||||
defaultPage: DefaultPage
|
||||
|
|
|
|||
|
|
@ -6,9 +6,18 @@ import type { IFile } from '@/modelTypes/IFile'
|
|||
import type { IAttachment } from '@/modelTypes/IAttachment'
|
||||
|
||||
export const SUPPORTED_IMAGE_SUFFIX = ['.jpeg', '.jpg', '.png', '.bmp', '.gif']
|
||||
export const SUPPORTED_PDF_SUFFIX = ['.pdf']
|
||||
|
||||
export function canPreviewImage(attachment: IAttachment): boolean {
|
||||
return SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.toLowerCase().endsWith(suffix))
|
||||
}
|
||||
|
||||
export function canPreviewPdf(attachment: IAttachment): boolean {
|
||||
return SUPPORTED_PDF_SUFFIX.some((suffix) => attachment.file.name.toLowerCase().endsWith(suffix))
|
||||
}
|
||||
|
||||
export function canPreview(attachment: IAttachment): boolean {
|
||||
return SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.toLowerCase().endsWith(suffix))
|
||||
return canPreviewImage(attachment) || canPreviewPdf(attachment)
|
||||
}
|
||||
|
||||
export default class AttachmentModel extends AbstractModel<IAttachment> implements IAttachment {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
|
|||
|
||||
projectId: IProject['id'] = 0
|
||||
bucketId: IBucket['id'] = 0
|
||||
buckets: IBucket[] = []
|
||||
|
||||
constructor(data: Partial<ITask> = {}) {
|
||||
super()
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
|
|||
defaultTaskRelationType: RELATION_KIND.RELATED,
|
||||
backgroundBrightness: null,
|
||||
alwaysShowBucketTaskCount: false,
|
||||
showLastViewed: true,
|
||||
sidebarWidth: null,
|
||||
commentSortOrder: 'asc',
|
||||
defaultPage: DEFAULT_PAGE.LAST_VISITED,
|
||||
|
|
|
|||
|
|
@ -195,6 +195,11 @@ const router = createRouter({
|
|||
name: 'migrate.start',
|
||||
component: () => import('@/views/migrate/Migration.vue'),
|
||||
},
|
||||
{
|
||||
path: '/migrate/csv',
|
||||
name: 'migrate.csv',
|
||||
component: () => import('@/views/migrate/MigrationCSV.vue'),
|
||||
},
|
||||
{
|
||||
path: '/migrate/:service',
|
||||
name: 'migrate.service',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import AbstractService from '../abstractService'
|
||||
|
||||
export interface ColumnMapping {
|
||||
column_index: number
|
||||
column_name: string
|
||||
attribute: TaskAttribute
|
||||
}
|
||||
|
||||
export type TaskAttribute =
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'due_date'
|
||||
| 'start_date'
|
||||
| 'end_date'
|
||||
| 'done'
|
||||
| 'priority'
|
||||
| 'labels'
|
||||
| 'project'
|
||||
| 'reminder'
|
||||
| 'ignore'
|
||||
|
||||
export const TASK_ATTRIBUTES: TaskAttribute[] = [
|
||||
'title',
|
||||
'description',
|
||||
'due_date',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'done',
|
||||
'priority',
|
||||
'labels',
|
||||
'project',
|
||||
'reminder',
|
||||
'ignore',
|
||||
]
|
||||
|
||||
export interface DetectionResult {
|
||||
columns: string[]
|
||||
delimiter: string
|
||||
quote_char: string
|
||||
date_format: string
|
||||
suggested_mapping: ColumnMapping[]
|
||||
preview_rows: string[][]
|
||||
}
|
||||
|
||||
export interface ImportConfig {
|
||||
delimiter: string
|
||||
quote_char: string
|
||||
date_format: string
|
||||
skip_rows: number
|
||||
mapping: ColumnMapping[]
|
||||
}
|
||||
|
||||
export interface PreviewTask {
|
||||
title: string
|
||||
description: string
|
||||
due_date?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
done: boolean
|
||||
priority: number
|
||||
labels?: string[]
|
||||
project?: string
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
tasks: PreviewTask[]
|
||||
total_rows: number
|
||||
}
|
||||
|
||||
export interface MigrationStatus {
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
export const SUPPORTED_DELIMITERS = [',', ';', '\t', '|'] as const
|
||||
|
||||
export const SUPPORTED_DATE_FORMATS = [
|
||||
'2006-01-02',
|
||||
'2006-01-02T15:04:05',
|
||||
'02/01/2006',
|
||||
'01/02/2006',
|
||||
'02-01-2006',
|
||||
'01-02-2006',
|
||||
'02.01.2006',
|
||||
'2006/01/02',
|
||||
'2006-01-02 15:04:05',
|
||||
] as const
|
||||
|
||||
export default class CSVMigrationService extends AbstractService {
|
||||
constructor() {
|
||||
super({})
|
||||
}
|
||||
|
||||
getStatus(): Promise<MigrationStatus> {
|
||||
return this.getM('/migration/csv/status')
|
||||
}
|
||||
|
||||
useCreateInterceptor() {
|
||||
return false
|
||||
}
|
||||
|
||||
async detect(file: File): Promise<DetectionResult> {
|
||||
return this.uploadFile(
|
||||
'/migration/csv/detect',
|
||||
file,
|
||||
'import',
|
||||
)
|
||||
}
|
||||
|
||||
async preview(file: File, config: ImportConfig): Promise<PreviewResult> {
|
||||
const data = new FormData()
|
||||
data.append('import', file)
|
||||
data.append('config', JSON.stringify(config))
|
||||
return this.uploadFormData('/migration/csv/preview', data)
|
||||
}
|
||||
|
||||
async migrate(file: File, config: ImportConfig): Promise<{ message: string }> {
|
||||
const data = new FormData()
|
||||
data.append('import', file)
|
||||
data.append('config', JSON.stringify(config))
|
||||
return this.uploadFormData('/migration/csv/migrate', data)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import UserModel, {getDisplayName, fetchAvatarBlobUrl, invalidateAvatarCache} fr
|
|||
import AvatarService from '@/services/avatar'
|
||||
import UserSettingsService from '@/services/userSettings'
|
||||
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
|
||||
import {useWebSocket} from '@/composables/useWebSocket'
|
||||
import {setModuleLoading} from '@/stores/helper'
|
||||
import {success, error} from '@/message'
|
||||
import {
|
||||
|
|
@ -140,6 +141,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
timeFormat: TIME_FORMAT.HOURS_24,
|
||||
defaultTaskRelationType: RELATION_KIND.RELATED,
|
||||
backgroundBrightness: 100,
|
||||
showLastViewed: true,
|
||||
sidebarWidth: null,
|
||||
commentSortOrder: 'asc',
|
||||
defaultPage: DEFAULT_PAGE.LAST_VISITED,
|
||||
|
|
@ -509,6 +511,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
}
|
||||
|
||||
async function logout() {
|
||||
const {disconnect} = useWebSocket()
|
||||
disconnect()
|
||||
|
||||
// Revoke the server session so the refresh token can't be reused.
|
||||
// Best-effort: if the network call fails, still clean up locally.
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@ import {acceptHMRUpdate, defineStore} from 'pinia'
|
|||
import LabelService from '@/services/label'
|
||||
import {success} from '@/message'
|
||||
import {i18n} from '@/i18n'
|
||||
import {createNewIndexer} from '@/indexes'
|
||||
import {setModuleLoading} from '@/stores/helper'
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
|
||||
const {add, remove, update, search} = createNewIndexer('labels', ['title', 'description'])
|
||||
|
||||
async function getAllLabels(page = 1): Promise<ILabel[]> {
|
||||
const labelService = new LabelService()
|
||||
const labels = await labelService.getAll({}, {}, page) as ILabel[]
|
||||
|
|
@ -48,12 +45,12 @@ export const useLabelStore = defineStore('label', () => {
|
|||
// **
|
||||
const filterLabelsByQuery = computed(() => {
|
||||
return (labelsToHide: ILabel[], query: string) => {
|
||||
if (query === '') return []
|
||||
const labelIdsToHide: number[] = labelsToHide.map(({id}) => id)
|
||||
|
||||
return search(query)
|
||||
?.filter(value => !labelIdsToHide.includes(value))
|
||||
.map(id => labels.value[id])
|
||||
|| []
|
||||
const q = query.toLowerCase()
|
||||
return labelsArray.value
|
||||
.filter(l => !labelIdsToHide.includes(l.id))
|
||||
.filter(l => l.title.toLowerCase().includes(q) || (l.description ?? '').toLowerCase().includes(q))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -75,17 +72,14 @@ export const useLabelStore = defineStore('label', () => {
|
|||
function setLabels(newLabels: ILabel[]) {
|
||||
newLabels.forEach(l => {
|
||||
labels.value[l.id] = l
|
||||
add(l)
|
||||
})
|
||||
}
|
||||
|
||||
function setLabel(label: ILabel) {
|
||||
labels.value[label.id] = {...label}
|
||||
update(label)
|
||||
}
|
||||
|
||||
function removeLabelById(label: ILabel) {
|
||||
remove(label)
|
||||
delete labels.value[label.id]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,15 +30,6 @@ vi.mock('@/stores/base', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/indexes', () => ({
|
||||
createNewIndexer: () => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
search: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function createMockProject(overrides: Partial<IProject>): IProject {
|
||||
return {
|
||||
id: 1,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import ProjectDuplicateService from '@/services/projectDuplicateService'
|
|||
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
|
||||
import {setModuleLoading} from '@/stores/helper'
|
||||
import {removeProjectFromHistory} from '@/modules/projectHistory'
|
||||
import {createNewIndexer} from '@/indexes'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
|
|
@ -21,8 +20,6 @@ import SavedFilterModel from '@/models/savedFilter'
|
|||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
import {PERMISSIONS} from '@/constants/permissions.ts'
|
||||
|
||||
const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
|
||||
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
const baseStore = useBaseStore()
|
||||
const router = useRouter()
|
||||
|
|
@ -99,32 +96,33 @@ export const useProjectStore = defineStore('project', () => {
|
|||
}
|
||||
})
|
||||
|
||||
function searchByQuery(query: string): IProject[] {
|
||||
if (query === '') return []
|
||||
const q = query.toLowerCase()
|
||||
return projectsArray.value.filter(p =>
|
||||
p.title.toLowerCase().includes(q) || (p.description ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
|
||||
const searchProjectAndFilter = computed(() => {
|
||||
return (query: string, includeArchived = false) => {
|
||||
return search(query)
|
||||
?.map(id => projects.value[id])
|
||||
.filter(project => project?.isArchived === includeArchived)
|
||||
|| []
|
||||
return searchByQuery(query).filter(project => project.isArchived === includeArchived)
|
||||
}
|
||||
})
|
||||
|
||||
const searchProject = computed(() => {
|
||||
return (query: string, includeArchived = false) => {
|
||||
return search(query)
|
||||
?.filter(value => value > 0)
|
||||
.map(id => projects.value[id])
|
||||
.filter(project => project?.isArchived === includeArchived)
|
||||
|| []
|
||||
return searchByQuery(query)
|
||||
.filter(p => p.id > 0)
|
||||
.filter(project => project.isArchived === includeArchived)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const searchSavedFilter = computed(() => {
|
||||
return (query: string, includeArchived = false) => {
|
||||
return search(query)
|
||||
?.filter(value => getSavedFilterIdFromProjectId(value) > 0)
|
||||
.map(id => projects.value[id])
|
||||
.filter(project => project?.isArchived === includeArchived)
|
||||
|| []
|
||||
return searchByQuery(query)
|
||||
.filter(p => getSavedFilterIdFromProjectId(p.id) > 0)
|
||||
.filter(project => project.isArchived === includeArchived)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -134,7 +132,6 @@ export const useProjectStore = defineStore('project', () => {
|
|||
|
||||
function setProject(project: IProject) {
|
||||
projects.value[project.id] = project
|
||||
update(project)
|
||||
|
||||
// FIXME: This should be a watcher, but using a watcher instead will sometimes crash browser processes.
|
||||
// Reverted from 31b7c1f217532bf388ba95a03f469508bee46f6a
|
||||
|
|
@ -154,7 +151,6 @@ export const useProjectStore = defineStore('project', () => {
|
|||
.filter(p => p.parentProjectId === project.id)
|
||||
.forEach(p => removeProjectById(p))
|
||||
|
||||
remove(project)
|
||||
delete projects.value[project.id]
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +272,6 @@ export const useProjectStore = defineStore('project', () => {
|
|||
|
||||
projects.value = {}
|
||||
setProjects(loadedProjects)
|
||||
loadedProjects.forEach(p => add(p))
|
||||
|
||||
return loadedProjects
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
/>
|
||||
<ImportHint v-if="tasksLoaded" />
|
||||
<div
|
||||
v-if="projectHistory.length > 0"
|
||||
v-if="authStore.settings.frontendSettings.showLastViewed !== false && projectHistory.length > 0"
|
||||
class="is-max-width-desktop has-text-start mbs-4"
|
||||
>
|
||||
<h3>{{ $t('home.lastViewed') }}</h3>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
<p>{{ $t('migrate.description') }}</p>
|
||||
<div class="migration-services">
|
||||
<RouterLink
|
||||
v-for="{name, id, icon} in availableMigrators"
|
||||
v-for="{name, id, icon, isCSVMigrator} in availableMigrators"
|
||||
:key="id"
|
||||
class="migration-service-link"
|
||||
:to="{name: 'migrate.service', params: {service: id}}"
|
||||
:to="isCSVMigrator ? {name: 'migrate.csv'} : {name: 'migrate.service', params: {service: id}}"
|
||||
>
|
||||
<img
|
||||
class="migration-service-image"
|
||||
|
|
@ -45,7 +45,10 @@ const availableMigrators = computed(() => configStore.availableMigrators
|
|||
}
|
||||
|
||||
.migration-service-link {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
inline-size: 100px;
|
||||
text-transform: capitalize;
|
||||
margin-inline-end: 1rem;
|
||||
|
|
@ -53,5 +56,8 @@ const availableMigrators = computed(() => configStore.availableMigrators
|
|||
|
||||
.migration-service-image {
|
||||
display: block;
|
||||
max-block-size: 80px;
|
||||
inline-size: auto;
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,490 @@
|
|||
<template>
|
||||
<div class="content csv-migration">
|
||||
<h1>{{ $t('migrate.titleService', {name: 'CSV'}) }}</h1>
|
||||
<p>{{ $t('migrate.csv.description') }}</p>
|
||||
|
||||
<Message
|
||||
v-if="error"
|
||||
variant="danger"
|
||||
class="mbe-4"
|
||||
>
|
||||
{{ error }}
|
||||
</Message>
|
||||
|
||||
<!-- Step 1: File Upload -->
|
||||
<div
|
||||
v-if="step === 'upload'"
|
||||
class="upload-step"
|
||||
>
|
||||
<p>{{ $t('migrate.csv.uploadDescription') }}</p>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
class="is-hidden"
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
@change="handleFileUpload"
|
||||
>
|
||||
<XButton
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading || undefined"
|
||||
@click="uploadInput?.click()"
|
||||
>
|
||||
{{ $t('migrate.csv.selectFile') }}
|
||||
</XButton>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Column Mapping -->
|
||||
<div
|
||||
v-else-if="step === 'mapping'"
|
||||
class="mapping-step"
|
||||
>
|
||||
<div class="mapping-header">
|
||||
<p>{{ $t('migrate.csv.columnMappingDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Parsing Options -->
|
||||
<div class="parsing-options card">
|
||||
<h3>{{ $t('migrate.csv.parsingOptions') }}</h3>
|
||||
<div class="options-grid">
|
||||
<div class="option-group">
|
||||
<label for="delimiter">{{ $t('migrate.csv.delimiter') }}</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
id="delimiter"
|
||||
v-model="config.delimiter"
|
||||
@change="updatePreview"
|
||||
>
|
||||
<option
|
||||
v-for="delim in SUPPORTED_DELIMITERS"
|
||||
:key="delim"
|
||||
:value="delim"
|
||||
>
|
||||
{{ getDelimiterLabel(delim) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label for="dateFormat">{{ $t('migrate.csv.dateFormat') }}</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
id="dateFormat"
|
||||
v-model="config.date_format"
|
||||
@change="updatePreview"
|
||||
>
|
||||
<option
|
||||
v-for="format in SUPPORTED_DATE_FORMATS"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>
|
||||
{{ getDateFormatLabel(format) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-group">
|
||||
<label for="skipRows">{{ $t('migrate.csv.skipRows') }}</label>
|
||||
<input
|
||||
id="skipRows"
|
||||
v-model.number="config.skip_rows"
|
||||
type="number"
|
||||
class="input"
|
||||
min="0"
|
||||
@change="updatePreview"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Mappings -->
|
||||
<div class="column-mappings card">
|
||||
<h3>{{ $t('migrate.csv.mapColumns') }}</h3>
|
||||
<div class="mappings-grid">
|
||||
<div
|
||||
v-for="(mapping, index) in config.mapping"
|
||||
:key="index"
|
||||
class="mapping-row"
|
||||
>
|
||||
<div class="column-name">
|
||||
<strong>{{ mapping.column_name }}</strong>
|
||||
<span
|
||||
v-if="detectionResult && detectionResult.preview_rows[0]"
|
||||
class="preview-value"
|
||||
>
|
||||
{{ $t('migrate.csv.example') }}: {{ detectionResult.preview_rows[0][index] || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
v-model="mapping.attribute"
|
||||
@change="updatePreview"
|
||||
>
|
||||
<option
|
||||
v-for="attr in TASK_ATTRIBUTES"
|
||||
:key="attr"
|
||||
:value="attr"
|
||||
>
|
||||
{{ getAttributeLabel(attr) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div
|
||||
v-if="previewResult"
|
||||
class="preview-section card"
|
||||
>
|
||||
<h3>{{ $t('migrate.csv.preview') }}</h3>
|
||||
<p>{{ $t('migrate.csv.previewDescription', {count: previewResult.total_rows}) }}</p>
|
||||
|
||||
<div class="preview-tasks">
|
||||
<div
|
||||
v-for="(task, index) in previewTasks"
|
||||
:key="index"
|
||||
@click.capture.prevent.stop
|
||||
>
|
||||
<SingleTaskInProject
|
||||
:the-task="task"
|
||||
disabled
|
||||
:can-mark-as-done="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<XButton
|
||||
variant="tertiary"
|
||||
@click="resetToUpload"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
:loading="isLoading"
|
||||
:disabled="!hasValidMapping || isLoading"
|
||||
@click="performImport"
|
||||
>
|
||||
{{ $t('migrate.csv.import') }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Success -->
|
||||
<div
|
||||
v-else-if="step === 'success'"
|
||||
class="success-step"
|
||||
>
|
||||
<Message class="mbe-4">
|
||||
{{ successMessage }}
|
||||
</Message>
|
||||
<XButton :to="{name: 'home'}">
|
||||
{{ $t('home.goToOverview') }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Message from '@/components/misc/Message.vue'
|
||||
import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject.vue'
|
||||
import TaskModel from '@/models/task'
|
||||
|
||||
import CSVMigrationService, {
|
||||
type DetectionResult,
|
||||
type ImportConfig,
|
||||
type PreviewResult,
|
||||
TASK_ATTRIBUTES,
|
||||
SUPPORTED_DELIMITERS,
|
||||
SUPPORTED_DATE_FORMATS,
|
||||
} from '@/services/migrator/csvMigration'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {getErrorText} from '@/message'
|
||||
|
||||
type Step = 'upload' | 'mapping' | 'success'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
useTitle(() => t('migrate.titleService', {name: 'CSV'}))
|
||||
|
||||
const csvService = shallowReactive(new CSVMigrationService())
|
||||
|
||||
const step = ref<Step>('upload')
|
||||
const error = ref('')
|
||||
const successMessage = ref('')
|
||||
const isLoading = ref(false)
|
||||
const uploadInput = ref<HTMLInputElement | null>(null)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const detectionResult = ref<DetectionResult | null>(null)
|
||||
const previewResult = ref<PreviewResult | null>(null)
|
||||
|
||||
const config = ref<ImportConfig>({
|
||||
delimiter: ',',
|
||||
quote_char: '"',
|
||||
date_format: '2006-01-02',
|
||||
skip_rows: 0,
|
||||
mapping: [],
|
||||
})
|
||||
|
||||
const previewTasks = computed(() => {
|
||||
if (!previewResult.value) return []
|
||||
return previewResult.value.tasks.map((pt, i) => new TaskModel({
|
||||
id: -(i + 1),
|
||||
title: pt.title || t('migrate.csv.untitled'),
|
||||
description: pt.description || '',
|
||||
done: pt.done,
|
||||
dueDate: pt.due_date || null,
|
||||
startDate: pt.start_date || null,
|
||||
endDate: pt.end_date || null,
|
||||
priority: pt.priority,
|
||||
labels: (pt.labels || []).map((l, li) => ({id: -(li + 1), title: l})),
|
||||
}))
|
||||
})
|
||||
|
||||
const hasValidMapping = computed(() => {
|
||||
if (!config.value.mapping.length) return false
|
||||
// At least one column should be mapped to title
|
||||
return config.value.mapping.some(m => m.attribute === 'title')
|
||||
})
|
||||
|
||||
// Map snake_case attribute names to translation keys
|
||||
function getAttributeLabel(attribute: string): string {
|
||||
const attributeMap: Record<string, string> = {
|
||||
title: 'task.attributes.title',
|
||||
description: 'task.attributes.description',
|
||||
due_date: 'task.attributes.dueDate',
|
||||
start_date: 'task.attributes.startDate',
|
||||
end_date: 'task.attributes.endDate',
|
||||
done: 'task.attributes.done',
|
||||
priority: 'task.attributes.priority',
|
||||
labels: 'task.attributes.labels',
|
||||
reminder: 'task.attributes.reminders',
|
||||
project: 'project.title',
|
||||
ignore: 'migrate.csv.ignore',
|
||||
}
|
||||
return t(attributeMap[attribute] || attribute)
|
||||
}
|
||||
|
||||
function getDelimiterLabel(delimiter: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
',': t('migrate.csv.delimiters.comma'),
|
||||
';': t('migrate.csv.delimiters.semicolon'),
|
||||
'\t': t('migrate.csv.delimiters.tab'),
|
||||
'|': t('migrate.csv.delimiters.pipe'),
|
||||
}
|
||||
return labels[delimiter] || delimiter
|
||||
}
|
||||
|
||||
function getDateFormatLabel(format: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'2006-01-02': 'YYYY-MM-DD (2024-01-15)',
|
||||
'2006-01-02T15:04:05': 'ISO DateTime (2024-01-15T10:30:00)',
|
||||
'02/01/2006': 'DD/MM/YYYY (15/01/2024)',
|
||||
'01/02/2006': 'MM/DD/YYYY (01/15/2024)',
|
||||
'02-01-2006': 'DD-MM-YYYY (15-01-2024)',
|
||||
'01-02-2006': 'MM-DD-YYYY (01-15-2024)',
|
||||
'02.01.2006': 'DD.MM.YYYY (15.01.2024)',
|
||||
'2006/01/02': 'YYYY/MM/DD (2024/01/15)',
|
||||
'2006-01-02 15:04:05': 'DateTime (2024-01-15 10:30:00)',
|
||||
}
|
||||
return labels[format] || format
|
||||
}
|
||||
|
||||
async function handleFileUpload() {
|
||||
const files = uploadInput.value?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
selectedFile.value = files[0]
|
||||
error.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await csvService.detect(selectedFile.value)
|
||||
detectionResult.value = result
|
||||
|
||||
// Apply detected values
|
||||
config.value = {
|
||||
delimiter: result.delimiter,
|
||||
quote_char: result.quote_char,
|
||||
date_format: result.date_format,
|
||||
skip_rows: 0,
|
||||
mapping: result.suggested_mapping,
|
||||
}
|
||||
|
||||
// Get initial preview
|
||||
await updatePreview()
|
||||
|
||||
step.value = 'mapping'
|
||||
} catch (e) {
|
||||
error.value = getErrorText(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePreview() {
|
||||
if (!selectedFile.value) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
previewResult.value = await csvService.preview(selectedFile.value, config.value)
|
||||
} catch (e) {
|
||||
error.value = getErrorText(e)
|
||||
previewResult.value = null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function performImport() {
|
||||
if (!selectedFile.value || !hasValidMapping.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const result = await csvService.migrate(selectedFile.value, config.value)
|
||||
successMessage.value = result.message
|
||||
|
||||
// Reload projects
|
||||
const projectStore = useProjectStore()
|
||||
await projectStore.loadAllProjects()
|
||||
|
||||
step.value = 'success'
|
||||
} catch (e) {
|
||||
error.value = getErrorText(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetToUpload() {
|
||||
step.value = 'upload'
|
||||
selectedFile.value = null
|
||||
detectionResult.value = null
|
||||
previewResult.value = null
|
||||
error.value = ''
|
||||
if (uploadInput.value) {
|
||||
uploadInput.value.value = ''
|
||||
}
|
||||
config.value = {
|
||||
delimiter: ',',
|
||||
quote_char: '"',
|
||||
date_format: '2006-01-02',
|
||||
skip_rows: 0,
|
||||
mapping: [],
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.csv-migration {
|
||||
max-inline-size: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--white);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.5rem;
|
||||
margin-block-end: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.mapping-header {
|
||||
margin-block-end: 1.5rem;
|
||||
|
||||
h2 {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.parsing-options {
|
||||
h3 {
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.option-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.column-mappings {
|
||||
h3 {
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mappings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--grey-100);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.column-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
.preview-value {
|
||||
font-size: 0.85rem;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
h3 {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-tasks {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-block-start: 1.5rem;
|
||||
}
|
||||
|
||||
.success-step {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<svg viewBox="0 0 88 88" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<!-- Document background -->
|
||||
<rect x="14" y="4" width="60" height="80" rx="4" fill="#4772FA"/>
|
||||
<!-- Folded corner -->
|
||||
<path d="M54 4 L74 24 L54 24 Z" fill="#2a5bd7"/>
|
||||
<!-- CSV text lines representing data rows -->
|
||||
<rect x="22" y="34" width="44" height="6" rx="2" fill="#ffffff"/>
|
||||
<rect x="22" y="46" width="44" height="6" rx="2" fill="#ffffff" opacity="0.8"/>
|
||||
<rect x="22" y="58" width="44" height="6" rx="2" fill="#ffffff" opacity="0.6"/>
|
||||
<rect x="22" y="70" width="30" height="6" rx="2" fill="#ffffff" opacity="0.4"/>
|
||||
<!-- Vertical separators (comma separators) -->
|
||||
<rect x="36" y="34" width="2" height="42" fill="#4772FA" opacity="0.3"/>
|
||||
<rect x="52" y="34" width="2" height="42" fill="#4772FA" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -4,11 +4,14 @@ import trelloIcon from './icons/trello.svg?url'
|
|||
import microsoftTodoIcon from './icons/microsoft-todo.svg?url'
|
||||
import vikunjaFileIcon from './icons/vikunja-file.png?url'
|
||||
import tickTickIcon from './icons/ticktick.svg?url'
|
||||
import wekanIcon from './icons/wekan.png?url'
|
||||
import csvIcon from './icons/csv.svg?url'
|
||||
|
||||
export interface Migrator {
|
||||
id: string
|
||||
name: string
|
||||
isFileMigrator?: boolean
|
||||
isCSVMigrator?: boolean
|
||||
icon: string
|
||||
}
|
||||
|
||||
|
|
@ -49,4 +52,17 @@ export const MIGRATORS = {
|
|||
icon: tickTickIcon as string,
|
||||
isFileMigrator: true,
|
||||
},
|
||||
wekan: {
|
||||
id: 'wekan',
|
||||
name: 'WeKan ®',
|
||||
icon: wekanIcon,
|
||||
isFileMigrator: true,
|
||||
},
|
||||
csv: {
|
||||
id: 'csv',
|
||||
name: 'CSV',
|
||||
icon: csvIcon as string,
|
||||
isFileMigrator: true,
|
||||
isCSVMigrator: true,
|
||||
},
|
||||
} as const satisfies IMigratorRecord
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@
|
|||
:key="task.id"
|
||||
:show-project="true"
|
||||
:the-task="task"
|
||||
:can-mark-as-done="(projectStore.projects[task.projectId]?.maxPermission ?? 0) > PERMISSIONS.READ"
|
||||
@taskUpdated="updateTasks"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -123,6 +124,7 @@ import {useProjectStore} from '@/stores/projects'
|
|||
import {useLabelStore} from '@/stores/labels'
|
||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import {PERMISSIONS} from '@/constants/permissions'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
dateFrom?: Date | string,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@
|
|||
class="has-text-grey-light"
|
||||
> > </span>
|
||||
</template>
|
||||
<BucketSelect
|
||||
:task="task"
|
||||
:can-write="canWrite"
|
||||
@update:task="Object.assign(task, $event)"
|
||||
/>
|
||||
</h6>
|
||||
|
||||
<ChecklistSummary :task="task" />
|
||||
|
|
@ -659,6 +664,7 @@ import RepeatAfter from '@/components/tasks/partials/RepeatAfter.vue'
|
|||
import TaskSubscription from '@/components/misc/Subscription.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import AssigneeList from '@/components/tasks/partials/AssigneeList.vue'
|
||||
import BucketSelect from '@/components/tasks/partials/BucketSelect.vue'
|
||||
import Reactions from '@/components/input/Reactions.vue'
|
||||
|
||||
import {uploadFile} from '@/helpers/attachments'
|
||||
|
|
@ -899,7 +905,7 @@ watch(
|
|||
}
|
||||
|
||||
try {
|
||||
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread']})
|
||||
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread', 'buckets']})
|
||||
Object.assign(task.value, loaded)
|
||||
taskColor.value = task.value.hexColor
|
||||
setActiveFields()
|
||||
|
|
|
|||
|
|
@ -123,6 +123,15 @@
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="settings.frontendSettings.showLastViewed"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ $t('user.settings.general.showLastViewed') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import {test, expect} from '../../support/fixtures'
|
|||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectViewFactory} from '../../factories/project_view'
|
||||
import {SavedFilterFactory} from '../../factories/saved_filter'
|
||||
|
||||
/**
|
||||
* Tests for filter autocomplete functionality, specifically for:
|
||||
|
|
@ -31,11 +30,6 @@ function getFilterInput(page) {
|
|||
test.describe('Filter Autocomplete', () => {
|
||||
test.beforeEach(async ({authenticatedPage, currentUser}) => {
|
||||
// authenticatedPage fixture triggers apiContext which sets up Factory.request
|
||||
await ProjectFactory.truncate()
|
||||
await TaskFactory.truncate()
|
||||
await ProjectViewFactory.truncate()
|
||||
await SavedFilterFactory.truncate()
|
||||
|
||||
const userId = currentUser.id
|
||||
|
||||
// Create projects - one with spaces in name (the bug case)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ test.describe('Parent Project Clear', () => {
|
|||
title: 'Child Project',
|
||||
parent_project_id: parentProjects[0].id,
|
||||
}, false)
|
||||
const childViews = await createDefaultViews(childProjects[0].id, 104, false)
|
||||
const childViews = await createDefaultViews(childProjects[0].id, 104)
|
||||
|
||||
// Navigate to the child project first
|
||||
await page.goto(`/projects/${childProjects[0].id}/${childViews[0].id}`)
|
||||
|
|
@ -75,7 +75,7 @@ test.describe('Parent Project Clear', () => {
|
|||
title: 'Test Child',
|
||||
parent_project_id: parentProjects[0].id,
|
||||
}, false)
|
||||
const childViews = await createDefaultViews(childProjects[0].id, 204, false)
|
||||
const childViews = await createDefaultViews(childProjects[0].id, 204)
|
||||
|
||||
// Navigate to the child project first
|
||||
await page.goto(`/projects/${childProjects[0].id}/${childViews[0].id}`)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ import {ProjectFactory} from '../../factories/project'
|
|||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectViewFactory} from '../../factories/project_view'
|
||||
|
||||
export async function createDefaultViews(projectId: number, startViewId = 1, truncate: boolean = true) {
|
||||
if (truncate) {
|
||||
await ProjectViewFactory.truncate()
|
||||
}
|
||||
export async function createDefaultViews(projectId: number, startViewId = 1) {
|
||||
const list = await ProjectViewFactory.create(1, {
|
||||
id: startViewId,
|
||||
project_id: projectId,
|
||||
|
|
@ -41,11 +38,8 @@ export async function createProjects(count: number = 1) {
|
|||
title: i => count === 1 ? 'First Project' : `Project ${i + 1}`,
|
||||
})
|
||||
|
||||
await TaskFactory.truncate()
|
||||
await ProjectViewFactory.truncate()
|
||||
|
||||
for (let i = 0; i < projects.length; i++) {
|
||||
const views = await createDefaultViews(projects[i].id, i * 4 + 1, false)
|
||||
const views = await createDefaultViews(projects[i].id, i * 4 + 1)
|
||||
projects[i].views = views
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,30 @@
|
|||
import {test, expect} from '../../support/fixtures'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {ProjectViewFactory} from '../../factories/project_view'
|
||||
import {updateUserSettings} from '../../support/updateUserSettings'
|
||||
import type {Page} from '@playwright/test'
|
||||
|
||||
async function visitProjectsToBuildHistory(page: Page, projects: any[]) {
|
||||
for (const project of projects) {
|
||||
const loadProjectPromise = page.waitForResponse(response =>
|
||||
response.url().includes(`/projects/${project.id}`) && response.request().method() === 'GET',
|
||||
)
|
||||
await page.goto(`/projects/${project.id}/${project.id}`)
|
||||
await loadProjectPromise
|
||||
await page.waitForFunction(
|
||||
(projectId) => {
|
||||
const history = JSON.parse(localStorage.getItem('projectHistory') || '[]')
|
||||
return history.some((h: any) => h.id === projectId)
|
||||
},
|
||||
project.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Project History', () => {
|
||||
test('should show a project history on the home page', async ({authenticatedPage: page}) => {
|
||||
test.setTimeout(60000)
|
||||
const projects = await ProjectFactory.create(7)
|
||||
await ProjectViewFactory.truncate()
|
||||
for (const p of projects) {
|
||||
await ProjectViewFactory.create(1, {
|
||||
id: p.id,
|
||||
|
|
@ -46,4 +64,80 @@ test.describe('Project History', () => {
|
|||
await expect(page.locator('.project-grid')).toContainText(projects[5].title)
|
||||
await expect(page.locator('.project-grid')).toContainText(projects[6].title)
|
||||
})
|
||||
|
||||
test('should hide the last viewed section when showLastViewed setting is disabled', async ({authenticatedPage: page, apiContext}) => {
|
||||
test.setTimeout(60000)
|
||||
const projects = await ProjectFactory.create(3)
|
||||
await ProjectViewFactory.truncate()
|
||||
for (const p of projects) {
|
||||
await ProjectViewFactory.create(1, {
|
||||
id: p.id,
|
||||
project_id: p.id,
|
||||
}, false)
|
||||
}
|
||||
|
||||
// Visit projects to build up history
|
||||
await visitProjectsToBuildHistory(page, projects)
|
||||
|
||||
// Go to overview and verify section is visible
|
||||
await page.goto('/')
|
||||
await expect(page.locator('body')).toContainText('Last viewed')
|
||||
|
||||
// Disable the setting via API
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'))
|
||||
await updateUserSettings(apiContext, token!, {
|
||||
frontendSettings: {
|
||||
showLastViewed: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Reload and verify section is hidden
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.locator('body')).not.toContainText('Last viewed')
|
||||
})
|
||||
|
||||
test('should show the last viewed section again when re-enabling showLastViewed', async ({authenticatedPage: page, apiContext}) => {
|
||||
test.setTimeout(60000)
|
||||
const projects = await ProjectFactory.create(2)
|
||||
await ProjectViewFactory.truncate()
|
||||
for (const p of projects) {
|
||||
await ProjectViewFactory.create(1, {
|
||||
id: p.id,
|
||||
project_id: p.id,
|
||||
}, false)
|
||||
}
|
||||
|
||||
// Navigate to app first so localStorage is accessible
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Disable the setting first
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'))
|
||||
await updateUserSettings(apiContext, token!, {
|
||||
frontendSettings: {
|
||||
showLastViewed: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Visit projects to build up history
|
||||
await visitProjectsToBuildHistory(page, projects)
|
||||
|
||||
// Verify section is hidden
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.locator('body')).not.toContainText('Last viewed')
|
||||
|
||||
// Re-enable the setting
|
||||
await updateUserSettings(apiContext, token!, {
|
||||
frontendSettings: {
|
||||
showLastViewed: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Reload and verify section is visible again
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.locator('body')).toContainText('Last viewed')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -132,4 +132,29 @@ test.describe('Project View Gantt', () => {
|
|||
|
||||
await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`))
|
||||
})
|
||||
|
||||
test('Should preserve date range query parameters after opening and closing a task modal', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1)
|
||||
await ProjectViewFactory.create(1, {id: 2, project_id: 1, view_kind: 1})
|
||||
await TaskFactory.create(1, {
|
||||
start_date: new Date(2022, 9, 1).toISOString(),
|
||||
end_date: new Date(2022, 9, 5).toISOString(),
|
||||
})
|
||||
await page.goto('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
|
||||
|
||||
// Verify the date range is shown
|
||||
await expect(page).toHaveURL(/dateFrom=2022-09-25/)
|
||||
await expect(page).toHaveURL(/dateTo=2022-11-05/)
|
||||
|
||||
// Double-click the task to open the modal
|
||||
await page.locator('.gantt-container .gantt-row-bars .gantt-bar').dblclick()
|
||||
await expect(page).toHaveURL(/\/tasks\//)
|
||||
|
||||
// Close the modal
|
||||
await page.locator('dialog[open] .modal-container > .close').click()
|
||||
|
||||
// Verify the date range query parameters are preserved
|
||||
await expect(page).toHaveURL(/dateFrom=2022-09-25/)
|
||||
await expect(page).toHaveURL(/dateTo=2022-11-05/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ async function createTaskWithBuckets(buckets, count = 1) {
|
|||
const data = await TaskFactory.create(count, {
|
||||
project_id: 1,
|
||||
})
|
||||
await TaskBucketFactory.truncate()
|
||||
for (const t of data) {
|
||||
await TaskBucketFactory.create(1, {
|
||||
task_id: t.id,
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ test.describe('Project View List', () => {
|
|||
}, false)
|
||||
|
||||
// Make task 2 a subtask of task 1
|
||||
await TaskRelationFactory.truncate()
|
||||
await TaskRelationFactory.create(1, {
|
||||
id: 1,
|
||||
task_id: 2,
|
||||
|
|
@ -143,7 +142,6 @@ test.describe('Project View List', () => {
|
|||
}, false)
|
||||
|
||||
// Make task 2 a subtask of task 1
|
||||
await TaskRelationFactory.truncate()
|
||||
await TaskRelationFactory.create(1, {
|
||||
id: 1,
|
||||
task_id: 2,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {UserFactory} from '../../factories/user'
|
|||
|
||||
test.describe('Team', () => {
|
||||
test('Creates a new team', async ({authenticatedPage: page}) => {
|
||||
await TeamFactory.truncate()
|
||||
await page.goto('/teams')
|
||||
|
||||
const newTeamName = 'New Team'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
import {test, expect} from '../../support/fixtures'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectViewFactory} from '../../factories/project_view'
|
||||
import {TaskBucketFactory} from '../../factories/task_buckets'
|
||||
import {TaskRelationFactory} from '../../factories/task_relation'
|
||||
|
||||
async function createKanbanTaskInBucket() {
|
||||
const projects = await ProjectFactory.create(1)
|
||||
const views = await ProjectViewFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
view_kind: 3,
|
||||
bucket_configuration_mode: 1,
|
||||
})
|
||||
const buckets = await BucketFactory.create(2, {
|
||||
project_view_id: views[0].id,
|
||||
})
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
await TaskBucketFactory.create(1, {
|
||||
task_id: tasks[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
project_view_id: views[0].id,
|
||||
})
|
||||
return {
|
||||
project: projects[0],
|
||||
view: views[0],
|
||||
buckets,
|
||||
task: tasks[0],
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Task Bucket Select', () => {
|
||||
test('Shows the current bucket name when opening a task from a kanban view', async ({authenticatedPage: page}) => {
|
||||
const {project, view, buckets, task} = await createKanbanTaskInBucket()
|
||||
|
||||
await page.goto(`/projects/${project.id}/${view.id}`)
|
||||
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
|
||||
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
|
||||
|
||||
await expect(page.locator('.task-view .subtitle')).toContainText(buckets[0].title)
|
||||
})
|
||||
|
||||
test('Can change the bucket from the task detail view', async ({authenticatedPage: page}) => {
|
||||
const {project, view, buckets, task} = await createKanbanTaskInBucket()
|
||||
|
||||
await page.goto(`/projects/${project.id}/${view.id}`)
|
||||
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
|
||||
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
|
||||
|
||||
// Click the bucket name to open the dropdown
|
||||
await page.locator('.task-view .subtitle .bucket-name').click()
|
||||
// Select the other bucket
|
||||
await page.locator('.task-view .subtitle .dropdown-item').filter({hasText: buckets[1].title}).click()
|
||||
|
||||
await expect(page.locator('.global-notification')).toContainText('Success')
|
||||
await expect(page.locator('.task-view .subtitle')).toContainText(buckets[1].title)
|
||||
})
|
||||
|
||||
test('Does not show the bucket selector when project has no kanban view', async ({authenticatedPage: page}) => {
|
||||
// Truncate leftover data from previous tests
|
||||
await BucketFactory.truncate()
|
||||
await TaskBucketFactory.truncate()
|
||||
await TaskRelationFactory.truncate()
|
||||
|
||||
const projects = await ProjectFactory.create(1)
|
||||
// Only create a list view, no kanban view
|
||||
const views = await ProjectViewFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
view_kind: 0,
|
||||
})
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
|
||||
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
|
||||
await page.locator('.tasks .task').filter({hasText: tasks[0].title}).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`))
|
||||
|
||||
await expect(page.locator('.task-view .subtitle .bucket-name')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Multiple kanban views', () => {
|
||||
async function createTaskWithMultipleKanbanViews() {
|
||||
// Truncate leftover task relations from previous tests
|
||||
await TaskRelationFactory.truncate()
|
||||
|
||||
const projects = await ProjectFactory.create(1)
|
||||
const listView = (await ProjectViewFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
view_kind: 0,
|
||||
}))[0]
|
||||
const kanbanView1 = (await ProjectViewFactory.create(1, {
|
||||
id: 2,
|
||||
project_id: projects[0].id,
|
||||
view_kind: 3,
|
||||
bucket_configuration_mode: 1,
|
||||
}, false))[0]
|
||||
const kanbanView2 = (await ProjectViewFactory.create(1, {
|
||||
id: 3,
|
||||
project_id: projects[0].id,
|
||||
view_kind: 3,
|
||||
bucket_configuration_mode: 1,
|
||||
}, false))[0]
|
||||
const bucketsView1 = await BucketFactory.create(2, {
|
||||
project_view_id: kanbanView1.id,
|
||||
})
|
||||
const bucketsView2 = await BucketFactory.create(2, {
|
||||
id: (i: number) => i + 2,
|
||||
project_view_id: kanbanView2.id,
|
||||
}, false)
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
await TaskBucketFactory.create(1, {
|
||||
task_id: tasks[0].id,
|
||||
bucket_id: bucketsView1[0].id,
|
||||
project_view_id: kanbanView1.id,
|
||||
})
|
||||
await TaskBucketFactory.create(1, {
|
||||
task_id: tasks[0].id,
|
||||
bucket_id: bucketsView2[0].id,
|
||||
project_view_id: kanbanView2.id,
|
||||
}, false)
|
||||
return {
|
||||
project: projects[0],
|
||||
listView,
|
||||
kanbanView1,
|
||||
kanbanView2,
|
||||
bucketsView1,
|
||||
bucketsView2,
|
||||
task: tasks[0],
|
||||
}
|
||||
}
|
||||
|
||||
test('Does not show the bucket selector when opening a task from the list view', async ({authenticatedPage: page}) => {
|
||||
const {project, listView, task} = await createTaskWithMultipleKanbanViews()
|
||||
|
||||
await page.goto(`/projects/${project.id}/${listView.id}`)
|
||||
await page.locator('.tasks .task').filter({hasText: task.title}).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
|
||||
|
||||
await expect(page.locator('.task-view .subtitle .bucket-name')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows the correct buckets when opening a task from the first kanban view', async ({authenticatedPage: page}) => {
|
||||
const {project, kanbanView1, bucketsView1, task} = await createTaskWithMultipleKanbanViews()
|
||||
|
||||
await page.goto(`/projects/${project.id}/${kanbanView1.id}`)
|
||||
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
|
||||
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
|
||||
|
||||
await expect(page.locator('.task-view .subtitle')).toContainText(bucketsView1[0].title)
|
||||
await page.locator('.task-view .subtitle .bucket-name').click()
|
||||
await expect(page.locator('.task-view .subtitle .dropdown-item')).toHaveCount(bucketsView1.length)
|
||||
for (const bucket of bucketsView1) {
|
||||
await expect(page.locator('.task-view .subtitle .dropdown-item').filter({hasText: bucket.title})).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Shows the correct buckets when opening a task from the second kanban view', async ({authenticatedPage: page}) => {
|
||||
const {project, kanbanView2, bucketsView2, task} = await createTaskWithMultipleKanbanViews()
|
||||
|
||||
await page.goto(`/projects/${project.id}/${kanbanView2.id}`)
|
||||
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
|
||||
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
|
||||
|
||||
await expect(page.locator('.task-view .subtitle')).toContainText(bucketsView2[0].title)
|
||||
await page.locator('.task-view .subtitle .bucket-name').click()
|
||||
await expect(page.locator('.task-view .subtitle .dropdown-item')).toHaveCount(bucketsView2.length)
|
||||
for (const bucket of bucketsView2) {
|
||||
await expect(page.locator('.task-view .subtitle .dropdown-item').filter({hasText: bucket.title})).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Keeps action buttons visible after changing the bucket', async ({authenticatedPage: page}) => {
|
||||
const {project, view, buckets, task} = await createKanbanTaskInBucket()
|
||||
|
||||
await page.goto(`/projects/${project.id}/${view.id}`)
|
||||
await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible()
|
||||
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`))
|
||||
|
||||
// Change the bucket
|
||||
await page.locator('.task-view .subtitle .bucket-name').click()
|
||||
await page.locator('.task-view .subtitle .dropdown-item').filter({hasText: buckets[1].title}).click()
|
||||
await expect(page.locator('.global-notification')).toContainText('Success')
|
||||
|
||||
// Action buttons should still be visible
|
||||
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Done'})).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -9,7 +9,6 @@ test.describe('Task comment pagination', () => {
|
|||
await ProjectFactory.create(1)
|
||||
await createDefaultViews(1)
|
||||
await TaskFactory.create(1, {id: 1})
|
||||
await TaskCommentFactory.truncate()
|
||||
})
|
||||
|
||||
test('shows pagination when more comments than configured page size', async ({authenticatedPage: page, apiContext}) => {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ test.describe('Comment sort order', () => {
|
|||
await ProjectFactory.create(1)
|
||||
await createDefaultViews(1)
|
||||
await TaskFactory.create(1, {id: 1})
|
||||
await TaskCommentFactory.truncate()
|
||||
})
|
||||
|
||||
test('defaults to oldest first', async ({authenticatedPage: page}) => {
|
||||
|
|
@ -199,7 +198,6 @@ test.describe('Comment sort order', () => {
|
|||
frontend_settings: JSON.stringify({commentSortOrder: 'desc'}),
|
||||
}))[0]
|
||||
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
|
||||
await TaskFactory.truncate()
|
||||
await TaskFactory.create(1, {id: 1, project_id: project.id, created_by_id: user.id})
|
||||
await createCommentsWithTimestamps(3)
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ test.describe('Date display setting', () => {
|
|||
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_12}),
|
||||
}))[0]
|
||||
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
|
||||
await TaskFactory.truncate()
|
||||
const task = (await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: project.id,
|
||||
|
|
@ -96,7 +95,6 @@ test.describe('Date display setting', () => {
|
|||
frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_24}),
|
||||
}))[0]
|
||||
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
|
||||
await TaskFactory.truncate()
|
||||
const task = (await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: project.id,
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ async function createProjectsWithTasks() {
|
|||
})
|
||||
|
||||
// Create views for both projects
|
||||
await ProjectViewFactory.truncate()
|
||||
|
||||
// List view for source project
|
||||
const sourceListView = await ProjectViewFactory.create(1, {
|
||||
id: 1,
|
||||
|
|
@ -45,7 +43,6 @@ async function createProjectsWithTasks() {
|
|||
})
|
||||
|
||||
// Create tasks in source project
|
||||
await TaskFactory.truncate()
|
||||
const tasks = await TaskFactory.create(3, {
|
||||
id: '{increment}',
|
||||
title: i => `Task ${i + 1}`,
|
||||
|
|
@ -53,7 +50,6 @@ async function createProjectsWithTasks() {
|
|||
})
|
||||
|
||||
// Assign tasks to bucket for kanban view
|
||||
await TaskBucketFactory.truncate()
|
||||
for (const task of tasks) {
|
||||
await TaskBucketFactory.create(1, {
|
||||
task_id: task.id,
|
||||
|
|
@ -180,7 +176,6 @@ test.describe('Drag Task to Project in Sidebar', () => {
|
|||
title: 'Source Project',
|
||||
})
|
||||
|
||||
await ProjectViewFactory.truncate()
|
||||
const sourceListView = await ProjectViewFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
|
|
@ -194,7 +189,6 @@ test.describe('Drag Task to Project in Sidebar', () => {
|
|||
owner_id: 1,
|
||||
})
|
||||
|
||||
await TaskFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
title: 'Test Task',
|
||||
|
|
@ -249,7 +243,6 @@ test.describe('Drag Task to Project in Sidebar', () => {
|
|||
permission: 0,
|
||||
})
|
||||
|
||||
await ProjectViewFactory.truncate()
|
||||
const sourceListView = await ProjectViewFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: sourceProject[0].id,
|
||||
|
|
@ -263,7 +256,6 @@ test.describe('Drag Task to Project in Sidebar', () => {
|
|||
view_kind: 0,
|
||||
}, false)
|
||||
|
||||
await TaskFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
title: 'Test Task',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ test.describe('Mention in task comment', () => {
|
|||
await ProjectFactory.create(1)
|
||||
await createDefaultViews(1)
|
||||
await TaskFactory.create(1, {id: 1})
|
||||
await TaskCommentFactory.truncate()
|
||||
})
|
||||
|
||||
test('typing @ in comment editor does not throw TypeError', async ({authenticatedPage: page}) => {
|
||||
|
|
|
|||
|
|
@ -157,7 +157,10 @@ test.describe('Home Page Task Overview', () => {
|
|||
})
|
||||
|
||||
test('Should show the cta buttons for new project when there are no tasks', async ({authenticatedPage: page}) => {
|
||||
await TaskFactory.truncate()
|
||||
// Need a project so that ShowTasks renders (which sets tasksLoaded=true),
|
||||
// but no tasks so the ImportHint becomes visible.
|
||||
const project = (await ProjectFactory.create())[0]
|
||||
await createDefaultViews(project.id)
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import {test, expect} from '../../support/fixtures'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {createDefaultViews} from '../project/prepareProjects'
|
||||
|
||||
test.describe('Read-only checkbox on Overview', () => {
|
||||
test('Should disable checkboxes for tasks from read-only shared projects', async ({authenticatedPage: page, apiContext, currentUser}) => {
|
||||
// Create a second user who will own the shared project
|
||||
const [otherUser] = await UserFactory.create(1, {
|
||||
id: 2,
|
||||
}, false)
|
||||
|
||||
// Create the own project (owned by test user, id=1)
|
||||
const [ownProject] = await ProjectFactory.create(1, {
|
||||
id: 1,
|
||||
title: 'Own Project',
|
||||
owner_id: currentUser.id,
|
||||
})
|
||||
const ownViews = await createDefaultViews(ownProject.id, 1)
|
||||
await BucketFactory.create(1, {
|
||||
project_view_id: ownViews[3].id,
|
||||
})
|
||||
|
||||
// Create the shared project (owned by user 2)
|
||||
const [sharedProject] = await ProjectFactory.create(1, {
|
||||
id: 2,
|
||||
title: 'Shared Read-Only Project',
|
||||
owner_id: otherUser.id,
|
||||
}, false)
|
||||
const sharedViews = await createDefaultViews(sharedProject.id, 5, false)
|
||||
await BucketFactory.create(1, {
|
||||
id: 2,
|
||||
project_view_id: sharedViews[3].id,
|
||||
}, false)
|
||||
|
||||
// Share the project read-only (permission=0) with the test user
|
||||
await UserProjectFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: sharedProject.id,
|
||||
user_id: currentUser.id,
|
||||
permission: 0,
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const soon = new Date(now.getTime() + 24 * 60 * 60 * 1000) // tomorrow
|
||||
|
||||
// Create a task in the own project
|
||||
await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
title: 'Own Task',
|
||||
project_id: ownProject.id,
|
||||
created_by_id: currentUser.id,
|
||||
due_date: soon.toISOString(),
|
||||
})
|
||||
|
||||
// Create a task in the shared read-only project
|
||||
await TaskFactory.create(1, {
|
||||
id: 2,
|
||||
title: 'Read Only Task',
|
||||
project_id: sharedProject.id,
|
||||
created_by_id: otherUser.id,
|
||||
due_date: soon.toISOString(),
|
||||
}, false)
|
||||
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Wait for both tasks to appear on the overview
|
||||
const ownTaskRow = page.locator('.single-task', {hasText: 'Own Task'})
|
||||
const readOnlyTaskRow = page.locator('.single-task', {hasText: 'Read Only Task'})
|
||||
|
||||
await expect(ownTaskRow).toBeVisible({timeout: 10000})
|
||||
await expect(readOnlyTaskRow).toBeVisible({timeout: 10000})
|
||||
|
||||
// The checkbox for the own task should be enabled
|
||||
const ownCheckbox = ownTaskRow.locator('input[type="checkbox"]')
|
||||
await expect(ownCheckbox).toBeEnabled()
|
||||
|
||||
// The checkbox for the read-only task should be disabled
|
||||
const readOnlyCheckbox = readOnlyTaskRow.locator('input[type="checkbox"]')
|
||||
await expect(readOnlyCheckbox).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
|
@ -20,13 +20,6 @@ test.describe('Subtask duplicate handling', () => {
|
|||
let subtask
|
||||
|
||||
test.beforeEach(async ({authenticatedPage: page, apiContext}) => {
|
||||
await Promise.all([
|
||||
ProjectFactory.truncate(),
|
||||
ProjectViewFactory.truncate(),
|
||||
TaskFactory.truncate(),
|
||||
TaskRelationFactory.truncate(),
|
||||
])
|
||||
|
||||
projectA = (await ProjectFactory.create(1, {id: 1, title: 'Project A'}))[0]
|
||||
await createViews(projectA.id, 1)
|
||||
projectB = (await ProjectFactory.create(1, {id: 2, title: 'Project B'}, false))[0]
|
||||
|
|
|
|||
|
|
@ -95,8 +95,6 @@ test.describe('Task', () => {
|
|||
buckets = await BucketFactory.create(1, {
|
||||
project_view_id: views[3].id,
|
||||
}) as Bucket[]
|
||||
await TaskFactory.truncate()
|
||||
await UserProjectFactory.truncate()
|
||||
})
|
||||
|
||||
test('Should be created new', async ({authenticatedPage: page}) => {
|
||||
|
|
@ -190,12 +188,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test.describe('Task Detail View', () => {
|
||||
test.beforeEach(async ({authenticatedPage: page}) => {
|
||||
await TaskCommentFactory.truncate()
|
||||
await LabelTaskFactory.truncate()
|
||||
await TaskAttachmentFactory.truncate()
|
||||
})
|
||||
|
||||
test('provides back navigation to the project in the list view', async ({authenticatedPage: page}) => {
|
||||
const tasks = await TaskFactory.create(1)
|
||||
const loadTasksPromise = page.waitForResponse(response =>
|
||||
|
|
@ -429,9 +421,9 @@ test.describe('Task', () => {
|
|||
|
||||
test('Can move a task to another project', async ({authenticatedPage: page}) => {
|
||||
const projects = await ProjectFactory.create(2)
|
||||
const views = await createDefaultViews(projects[0].id)
|
||||
const views = await createDefaultViews(projects[0].id, 10)
|
||||
// Also create views for the target project
|
||||
await createDefaultViews(projects[1].id)
|
||||
await createDefaultViews(projects[1].id, 14)
|
||||
await BucketFactory.create(2, {
|
||||
project_view_id: views[3].id,
|
||||
})
|
||||
|
|
@ -471,8 +463,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Can add an assignee to a task', async ({authenticatedPage: page}) => {
|
||||
await TaskAssigneeFactory.truncate()
|
||||
|
||||
// Create users with IDs starting at 100 to avoid conflict with logged-in user (ID 1)
|
||||
// Don't truncate to preserve the authenticated user from the fixture
|
||||
const users = await UserFactory.create(5, {
|
||||
|
|
@ -538,7 +528,6 @@ test.describe('Task', () => {
|
|||
id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
await LabelFactory.truncate()
|
||||
const newLabelText = 'some new label'
|
||||
|
||||
await page.goto(`/tasks/${tasks[0].id}`)
|
||||
|
|
@ -559,7 +548,6 @@ test.describe('Task', () => {
|
|||
project_id: 1,
|
||||
})
|
||||
const labels = await LabelFactory.create(1)
|
||||
await LabelTaskFactory.truncate()
|
||||
|
||||
await page.goto(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
|
@ -572,7 +560,6 @@ test.describe('Task', () => {
|
|||
project_id: projects[0].id,
|
||||
})
|
||||
const labels = await LabelFactory.create(1)
|
||||
await LabelTaskFactory.truncate()
|
||||
await TaskBucketFactory.create(1, {
|
||||
task_id: tasks[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
|
|
@ -722,7 +709,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Can paste an image into the description editor which uploads it as an attachment', async ({authenticatedPage: page}) => {
|
||||
await TaskAttachmentFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
}) as Task[]
|
||||
|
|
@ -745,7 +731,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Can set a reminder', async ({authenticatedPage: page}) => {
|
||||
await TaskReminderFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
|
|
@ -764,7 +749,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Allows to set a relative reminder when the task already has a due date', async ({authenticatedPage: page}) => {
|
||||
await TaskReminderFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
|
|
@ -785,7 +769,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Allows to set a relative reminder when the task already has a start date', async ({authenticatedPage: page}) => {
|
||||
await TaskReminderFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
|
|
@ -806,7 +789,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Allows to set a custom relative reminder when the task already has a due date', async ({authenticatedPage: page}) => {
|
||||
await TaskReminderFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
|
|
@ -831,7 +813,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Allows to set a fixed reminder when the task already has a due date', async ({authenticatedPage: page}) => {
|
||||
await TaskReminderFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
|
|
@ -856,7 +837,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Does not auto-save when clicking a date in the absolute reminder picker', async ({authenticatedPage: page}) => {
|
||||
await TaskReminderFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
|
|
@ -900,7 +880,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Shows Confirm button for absolute date reminder when task has no due date', async ({authenticatedPage: page}) => {
|
||||
await TaskReminderFactory.truncate()
|
||||
// Task with no due_date — defaultRelativeTo will be null
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
|
|
@ -949,7 +928,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Can add an attachment to a task', async ({authenticatedPage: page}) => {
|
||||
await TaskAttachmentFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
})
|
||||
|
|
@ -959,13 +937,11 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Can add an attachment to a task and see it appearing on kanban', async ({authenticatedPage: page}) => {
|
||||
await TaskAttachmentFactory.truncate()
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
const labels = await LabelFactory.create(1)
|
||||
await LabelTaskFactory.truncate()
|
||||
await TaskBucketFactory.create(1, {
|
||||
task_id: tasks[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
|
|
@ -1121,8 +1097,6 @@ test.describe('Task', () => {
|
|||
})
|
||||
|
||||
test('Should render an image from attachment', async ({authenticatedPage: page, apiContext}) => {
|
||||
await TaskAttachmentFactory.truncate()
|
||||
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: '',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {ProjectFactory} from '../../factories/project'
|
|||
test.describe('TipTap Editor Save', () => {
|
||||
test.beforeEach(async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1)
|
||||
await TaskFactory.truncate()
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ test.describe('Email Confirmation', () => {
|
|||
let confirmationToken
|
||||
|
||||
test.beforeEach(async ({page, apiContext}) => {
|
||||
await UserFactory.truncate()
|
||||
await TokenFactory.truncate()
|
||||
|
||||
// Create a user with status = 1 (StatusEmailConfirmationRequired)
|
||||
const users = await UserFactory.create(1, {
|
||||
username: 'unconfirmeduser',
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ test.describe('Login', () => {
|
|||
test('Should log in with the right credentials', async ({page}) => {
|
||||
await page.goto('/login')
|
||||
await login(page)
|
||||
await expect(page.locator('main h2')).toContainText(`Hi ${credentials.username}!`)
|
||||
await expect(page.locator('main h2')).toContainText(credentials.username)
|
||||
})
|
||||
|
||||
test('Should fail with a bad password', async ({page}) => {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ test.describe('Log out', () => {
|
|||
|
||||
test('Should clear the project history after logging the user out', async ({authenticatedPage: page}) => {
|
||||
const projects = await ProjectFactory.create(1)
|
||||
await ProjectViewFactory.truncate()
|
||||
await ProjectViewFactory.create(1, {
|
||||
id: projects[0].id,
|
||||
project_id: projects[0].id,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ test.describe('OpenID Login', () => {
|
|||
|
||||
// Should redirect back to the app
|
||||
await expect(page).toHaveURL(/\//)
|
||||
await expect(page.locator('main.app-content .content h2')).toContainText('test!')
|
||||
await expect(page.locator('main.app-content .content h2')).toContainText('test')
|
||||
await expect(page.locator('.show-tasks h3')).toContainText('Current Tasks')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ test.describe('Password Reset', () => {
|
|||
let user: UserAttributes
|
||||
|
||||
test.beforeEach(async ({page, apiContext}) => {
|
||||
await UserFactory.truncate()
|
||||
await TokenFactory.truncate()
|
||||
const users = await UserFactory.create(1)
|
||||
user = users[0] as UserAttributes
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ test.describe('Registration', () => {
|
|||
await page.locator('#password').fill(fixture.password)
|
||||
await page.locator('#register-submit').click()
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(page.locator('main h2')).toContainText(`Hi ${fixture.username}!`)
|
||||
await expect(page.locator('main h2')).toContainText(fixture.username)
|
||||
})
|
||||
|
||||
test('Should fail', async ({page, apiContext}) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import {test, expect} from '../../support/fixtures'
|
||||
import {openWs, waitForMessage, authenticateWs, subscribeWs, closeWs} from '../../support/websocket'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {ProjectViewFactory} from '../../factories/project_view'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {TEST_PASSWORD} from '../../support/constants'
|
||||
import type {APIRequestContext} from '@playwright/test'
|
||||
|
||||
async function loginRaw(apiContext: APIRequestContext, user: {username: string}): Promise<{token: string}> {
|
||||
const response = await apiContext.post('login', {
|
||||
data: {username: user.username, password: TEST_PASSWORD},
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
test.describe('WebSocket Comment Notifications', () => {
|
||||
|
||||
test('receives notification when mentioned in a task comment', async ({apiContext, userToken, currentUser}) => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
await authenticateWs(ws, userToken)
|
||||
subscribeWs(ws, 'notification.created')
|
||||
|
||||
// Create a second user who will post the comment
|
||||
const [commenter] = await UserFactory.create(1, {id: 100}, false)
|
||||
const {token: commenterToken} = await loginRaw(apiContext, commenter)
|
||||
|
||||
// Seed a project owned by the commenter with a task
|
||||
await ProjectFactory.create(1, {id: 100, owner_id: 100}, false)
|
||||
await ProjectViewFactory.create(1, {id: 100, project_id: 100}, false)
|
||||
await TaskFactory.create(1, {id: 100, project_id: 100, created_by_id: 100}, false)
|
||||
|
||||
// Share the project with currentUser so the mention access check passes
|
||||
await UserProjectFactory.create(1, {id: 100, project_id: 100, user_id: 1}, false)
|
||||
|
||||
// Commenter posts a comment mentioning currentUser
|
||||
const commentBody = `<p>Hey <mention-user data-id="${currentUser.username}">@${currentUser.username}</mention-user> check this out</p>`
|
||||
const commentResponse = await apiContext.put('tasks/100/comments', {
|
||||
data: {comment: commentBody},
|
||||
headers: {Authorization: `Bearer ${commenterToken}`},
|
||||
})
|
||||
expect(commentResponse.ok()).toBe(true)
|
||||
|
||||
// currentUser should receive the notification via WebSocket
|
||||
const msg = await waitForMessage(ws, 15000)
|
||||
expect(msg.event).toBe('notification.created')
|
||||
expect(msg.data).toBeDefined()
|
||||
|
||||
// The notification payload must include a valid created timestamp (not zero)
|
||||
const created = new Date(msg.data.created)
|
||||
expect(created.getFullYear()).toBeGreaterThanOrEqual(2020)
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import {test, expect} from '../../support/fixtures'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {TEST_PASSWORD} from '../../support/constants'
|
||||
|
||||
test.describe('WebSocket Frontend Integration', () => {
|
||||
|
||||
test('notification badge updates in real-time when added to team', async ({
|
||||
authenticatedPage: page,
|
||||
apiContext,
|
||||
currentUser,
|
||||
}) => {
|
||||
// Navigate to the app so WebSocket connects
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Verify no unread indicator initially
|
||||
await expect(page.locator('.notifications .unread-indicator')).toHaveCount(0)
|
||||
|
||||
// Create a second user who will add currentUser to a team
|
||||
const [userA] = await UserFactory.create(1, {id: 100}, false)
|
||||
const loginResponse = await apiContext.post('login', {
|
||||
data: {username: userA.username, password: TEST_PASSWORD},
|
||||
})
|
||||
const {token: tokenA} = await loginResponse.json()
|
||||
|
||||
// User A creates a team
|
||||
const teamResponse = await apiContext.put('teams', {
|
||||
data: {name: 'Real-Time Test Team'},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
const team = await teamResponse.json()
|
||||
|
||||
// User A adds currentUser to the team — this triggers a notification
|
||||
await apiContext.put(`teams/${team.id}/members`, {
|
||||
data: {username: currentUser.username},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
|
||||
// The unread indicator should appear without page refresh
|
||||
await expect(page.locator('.notifications .unread-indicator')).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
})
|
||||
|
||||
test('notification appears in dropdown after real-time delivery', async ({
|
||||
authenticatedPage: page,
|
||||
apiContext,
|
||||
currentUser,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Create user A and trigger notification
|
||||
const [userA] = await UserFactory.create(1, {id: 100}, false)
|
||||
const loginResponse = await apiContext.post('login', {
|
||||
data: {username: userA.username, password: TEST_PASSWORD},
|
||||
})
|
||||
const {token: tokenA} = await loginResponse.json()
|
||||
|
||||
const teamResponse = await apiContext.put('teams', {
|
||||
data: {name: 'Dropdown Test Team'},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
const team = await teamResponse.json()
|
||||
|
||||
await apiContext.put(`teams/${team.id}/members`, {
|
||||
data: {username: currentUser.username},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
|
||||
// Wait for unread indicator then click the bell
|
||||
await expect(page.locator('.notifications .unread-indicator')).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await page.locator('.notifications .trigger-button').click()
|
||||
|
||||
// Notification dropdown should contain the team notification
|
||||
const notificationsList = page.locator('.notifications .notifications-list')
|
||||
await expect(notificationsList).toBeVisible()
|
||||
await expect(notificationsList.locator('.single-notification')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('websocket disconnects on logout', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Perform logout — click user menu then logout button
|
||||
await page.locator('.navbar .username-dropdown-trigger').click()
|
||||
await page.locator('.navbar .dropdown-item').filter({hasText: 'Logout'}).click()
|
||||
|
||||
// After logout, should redirect to login page
|
||||
await expect(page).toHaveURL(/\/login/, {timeout: 5000})
|
||||
|
||||
// Verify the notification bell is gone (no authenticated UI)
|
||||
await expect(page.locator('.notifications .trigger-button')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
import {test, expect} from '../../support/fixtures'
|
||||
import {openWs, waitForMessage, sendMessage, authenticateWs, subscribeWs, collectMessages, closeWs} from '../../support/websocket'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {TEST_PASSWORD} from '../../support/constants'
|
||||
import type {APIRequestContext} from '@playwright/test'
|
||||
|
||||
/** Login without setting page localStorage — just returns the token. */
|
||||
async function loginRaw(apiContext: APIRequestContext, user: {username: string}): Promise<{token: string}> {
|
||||
const response = await apiContext.post('login', {
|
||||
data: {username: user.username, password: TEST_PASSWORD},
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
test.describe('WebSocket Protocol', () => {
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test('authenticates with valid token', async ({userToken}) => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
const msg = await authenticateWs(ws, userToken)
|
||||
expect(msg.action).toBe('auth.success')
|
||||
expect(msg.success).toBe(true)
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects invalid token', async () => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
sendMessage(ws, {action: 'auth', token: 'invalid-token'})
|
||||
const msg = await waitForMessage(ws)
|
||||
expect(msg.error).toBe('invalid_token')
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
|
||||
test('closes connection after auth timeout', async () => {
|
||||
test.setTimeout(45000)
|
||||
const ws = await openWs()
|
||||
const closed = new Promise<{code: number; reason: string}>((resolve) => {
|
||||
ws.on('close', (code, reason) => {
|
||||
resolve({code, reason: reason.toString()})
|
||||
})
|
||||
})
|
||||
const result = await closed
|
||||
// websocket StatusPolicyViolation = 1008
|
||||
expect(result.code).toBe(1008)
|
||||
})
|
||||
|
||||
test('rejects double authentication', async ({userToken}) => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
await authenticateWs(ws, userToken)
|
||||
sendMessage(ws, {action: 'auth', token: userToken})
|
||||
const msg = await waitForMessage(ws)
|
||||
expect(msg.error).toBe('already_authenticated')
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subscribe / Unsubscribe Events', () => {
|
||||
test('subscribes to valid event', async ({userToken}) => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
await authenticateWs(ws, userToken)
|
||||
sendMessage(ws, {action: 'subscribe', event: 'notification.created'})
|
||||
// No error response means success — verify by collecting messages
|
||||
// for a short window. If there was an error, it would arrive.
|
||||
const messages = await collectMessages(ws, 500)
|
||||
const errors = messages.filter(m => m.error)
|
||||
expect(errors).toHaveLength(0)
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects invalid event', async ({userToken}) => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
await authenticateWs(ws, userToken)
|
||||
sendMessage(ws, {action: 'subscribe', event: 'nonexistent.event'})
|
||||
const msg = await waitForMessage(ws)
|
||||
expect(msg.error).toBe('invalid_event')
|
||||
expect(msg.event).toBe('nonexistent.event')
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
|
||||
test('requires auth before subscribe', async () => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
sendMessage(ws, {action: 'subscribe', event: 'notification.created'})
|
||||
const msg = await waitForMessage(ws)
|
||||
expect(msg.error).toBe('auth_required')
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
|
||||
test('unsubscribe stops receiving events', async ({apiContext, userToken, currentUser}) => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
await authenticateWs(ws, userToken)
|
||||
subscribeWs(ws, 'notification.created')
|
||||
|
||||
// Create a second user to trigger the notification
|
||||
const [userA] = await UserFactory.create(1, {id: 100}, false)
|
||||
const {token: tokenA} = await loginRaw(apiContext, userA)
|
||||
|
||||
// User A creates a team
|
||||
const teamResponse = await apiContext.put('teams', {
|
||||
data: {name: 'Test Team'},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
const team = await teamResponse.json()
|
||||
|
||||
// Unsubscribe before the notification is triggered
|
||||
sendMessage(ws, {action: 'unsubscribe', event: 'notification.created'})
|
||||
// Give the server a moment to process the unsubscribe
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
|
||||
// Now add currentUser to team — should NOT receive WS notification
|
||||
await apiContext.put(`teams/${team.id}/members`, {
|
||||
data: {username: currentUser.username},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
|
||||
// Collect messages for 2 seconds — should get none
|
||||
const messages = await collectMessages(ws, 2000)
|
||||
const notifications = messages.filter(m => m.event === 'notification.created')
|
||||
expect(notifications).toHaveLength(0)
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Message Delivery', () => {
|
||||
test('receives notification when added to team', async ({apiContext, userToken, currentUser}) => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
await authenticateWs(ws, userToken)
|
||||
subscribeWs(ws, 'notification.created')
|
||||
|
||||
// Create a second user (the doer)
|
||||
const [userA] = await UserFactory.create(1, {id: 100}, false)
|
||||
const {token: tokenA} = await loginRaw(apiContext, userA)
|
||||
|
||||
// User A creates a team
|
||||
const teamResponse = await apiContext.put('teams', {
|
||||
data: {name: 'Notification Test Team'},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
const team = await teamResponse.json()
|
||||
|
||||
// User A adds currentUser to the team
|
||||
const addResponse = await apiContext.put(`teams/${team.id}/members`, {
|
||||
data: {username: currentUser.username},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
expect(addResponse.ok()).toBe(true)
|
||||
|
||||
// currentUser should receive the notification via WebSocket
|
||||
const msg = await waitForMessage(ws, 10000)
|
||||
expect(msg.event).toBe('notification.created')
|
||||
expect(msg.data).toBeDefined()
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
|
||||
test('doer does not receive own notification', async ({apiContext, userToken}) => {
|
||||
const ws = await openWs()
|
||||
try {
|
||||
await authenticateWs(ws, userToken)
|
||||
subscribeWs(ws, 'notification.created')
|
||||
|
||||
// Create a second user
|
||||
const [otherUser] = await UserFactory.create(1, {id: 100}, false)
|
||||
|
||||
// currentUser creates a team (they are the doer)
|
||||
const teamResponse = await apiContext.put('teams', {
|
||||
data: {name: 'Doer Test Team'},
|
||||
headers: {Authorization: `Bearer ${userToken}`},
|
||||
})
|
||||
const team = await teamResponse.json()
|
||||
|
||||
// currentUser adds otherUser — currentUser is the doer
|
||||
await apiContext.put(`teams/${team.id}/members`, {
|
||||
data: {username: otherUser.username},
|
||||
headers: {Authorization: `Bearer ${userToken}`},
|
||||
})
|
||||
|
||||
// currentUser should NOT receive a notification (they did the action)
|
||||
const messages = await collectMessages(ws, 3000)
|
||||
const notifications = messages.filter(m => m.event === 'notification.created')
|
||||
expect(notifications).toHaveLength(0)
|
||||
} finally {
|
||||
closeWs(ws)
|
||||
}
|
||||
})
|
||||
|
||||
test('multiple connections receive same notification', async ({apiContext, userToken, currentUser}) => {
|
||||
const ws1 = await openWs()
|
||||
const ws2 = await openWs()
|
||||
try {
|
||||
// Both connections authenticate as the same user
|
||||
await authenticateWs(ws1, userToken)
|
||||
await authenticateWs(ws2, userToken)
|
||||
subscribeWs(ws1, 'notification.created')
|
||||
subscribeWs(ws2, 'notification.created')
|
||||
|
||||
// Create a second user to trigger notification
|
||||
const [userA] = await UserFactory.create(1, {id: 100}, false)
|
||||
const {token: tokenA} = await loginRaw(apiContext, userA)
|
||||
|
||||
const teamResponse = await apiContext.put('teams', {
|
||||
data: {name: 'Multi-Connection Team'},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
const team = await teamResponse.json()
|
||||
|
||||
await apiContext.put(`teams/${team.id}/members`, {
|
||||
data: {username: currentUser.username},
|
||||
headers: {Authorization: `Bearer ${tokenA}`},
|
||||
})
|
||||
|
||||
// Both connections should receive the notification
|
||||
const [msg1, msg2] = await Promise.all([
|
||||
waitForMessage(ws1, 10000),
|
||||
waitForMessage(ws2, 10000),
|
||||
])
|
||||
expect(msg1.event).toBe('notification.created')
|
||||
expect(msg2.event).toBe('notification.created')
|
||||
} finally {
|
||||
closeWs(ws1)
|
||||
closeWs(ws2)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -96,4 +96,17 @@ export class Factory {
|
|||
static async truncate() {
|
||||
await this.seed(this.table, null)
|
||||
}
|
||||
|
||||
static async truncateAll() {
|
||||
const response = await this.request.delete('test/all', {
|
||||
headers: {
|
||||
'Authorization': process.env.VIKUNJA_SERVICE_TESTINGTOKEN || 'averyLongSecretToSe33dtheDB',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok()) {
|
||||
const body = await response.json()
|
||||
throw new Error(`Failed to truncate all tables (${response.status()}): ${body.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import {TaskFactory} from '../factories/task'
|
|||
import {TaskBucketFactory} from '../factories/task_buckets'
|
||||
|
||||
export async function createTasksWithPriorities(buckets?: any[]) {
|
||||
await TaskFactory.truncate()
|
||||
|
||||
const highPriorityTask1 = (await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
|
|
@ -34,7 +32,6 @@ export async function createTasksWithPriorities(buckets?: any[]) {
|
|||
|
||||
// If buckets are provided (for Kanban), add tasks to buckets
|
||||
if (buckets && buckets.length > 0) {
|
||||
await TaskBucketFactory.truncate()
|
||||
await TaskBucketFactory.create(1, {
|
||||
task_id: highPriorityTask1.id,
|
||||
bucket_id: buckets[0].id,
|
||||
|
|
@ -64,8 +61,6 @@ export async function createTasksWithPriorities(buckets?: any[]) {
|
|||
}
|
||||
|
||||
export async function createTasksWithSearch(buckets?: any[]) {
|
||||
await TaskFactory.truncate()
|
||||
|
||||
const task1 = (await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
|
|
@ -92,7 +87,6 @@ export async function createTasksWithSearch(buckets?: any[]) {
|
|||
|
||||
// If buckets are provided (for Kanban), add tasks to buckets
|
||||
if (buckets && buckets.length > 0) {
|
||||
await TaskBucketFactory.truncate()
|
||||
await TaskBucketFactory.create(1, {
|
||||
task_id: task1.id,
|
||||
bucket_id: buckets[0].id,
|
||||
|
|
|
|||
|
|
@ -8,16 +8,17 @@ export const test = base.extend<{
|
|||
currentUser: any;
|
||||
userToken: string;
|
||||
}>({
|
||||
apiContext: async ({playwright}, use) => {
|
||||
apiContext: [async ({playwright}, use) => {
|
||||
const baseURL = process.env.API_URL || 'http://localhost:3456/api/v1/'
|
||||
const apiContext = await playwright.request.newContext({
|
||||
baseURL,
|
||||
})
|
||||
|
||||
Factory.setRequestContext(apiContext)
|
||||
await Factory.truncateAll()
|
||||
await use(apiContext)
|
||||
await apiContext.dispose()
|
||||
},
|
||||
}, {auto: true}],
|
||||
|
||||
currentUser: async ({apiContext}, use) => {
|
||||
const user = await createFakeUser()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import WebSocket from 'ws'
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:3456/api/v1'
|
||||
|
||||
export interface WsMessage {
|
||||
event?: string
|
||||
action?: string
|
||||
success?: boolean
|
||||
error?: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebSocket URL derived from the API base URL.
|
||||
*/
|
||||
export function getWsUrl(): string {
|
||||
return API_URL.replace(/\/+$/, '').replace(/^http/, 'ws') + '/ws'
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a raw WebSocket connection to the API.
|
||||
*/
|
||||
export function openWs(): Promise<WebSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(getWsUrl())
|
||||
ws.on('open', () => resolve(ws))
|
||||
ws.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the next message on a WebSocket connection.
|
||||
*/
|
||||
export function waitForMessage(ws: WebSocket, timeout = 5000): Promise<WsMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WebSocket message timeout')), timeout)
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timer)
|
||||
resolve(JSON.parse(data.toString()))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON message on the WebSocket.
|
||||
*/
|
||||
export function sendMessage(ws: WebSocket, msg: object): void {
|
||||
ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates a WebSocket connection and returns the auth.success message.
|
||||
*/
|
||||
export async function authenticateWs(ws: WebSocket, token: string): Promise<WsMessage> {
|
||||
sendMessage(ws, {action: 'auth', token})
|
||||
const msg = await waitForMessage(ws)
|
||||
if (msg.action !== 'auth.success') {
|
||||
throw new Error(`Expected auth.success, got: ${JSON.stringify(msg)}`)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to an event on an authenticated WebSocket connection.
|
||||
*/
|
||||
export function subscribeWs(ws: WebSocket, event: string): void {
|
||||
sendMessage(ws, {action: 'subscribe', event})
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all messages received within a time window.
|
||||
*/
|
||||
export function collectMessages(ws: WebSocket, duration: number): Promise<WsMessage[]> {
|
||||
return new Promise((resolve) => {
|
||||
const messages: WsMessage[] = []
|
||||
const handler = (data: WebSocket.Data) => {
|
||||
messages.push(JSON.parse(data.toString()))
|
||||
}
|
||||
ws.on('message', handler)
|
||||
setTimeout(() => {
|
||||
ws.off('message', handler)
|
||||
resolve(messages)
|
||||
}, duration)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a WebSocket connection safely.
|
||||
*/
|
||||
export function closeWs(ws: WebSocket): void {
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
7
go.mod
7
go.mod
|
|
@ -29,9 +29,10 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/config v1.32.10
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2
|
||||
github.com/aws/smithy-go v1.24.1
|
||||
github.com/aws/smithy-go v1.24.2
|
||||
github.com/bbrks/go-blurhash v1.1.1
|
||||
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/d4l3k/messagediff v1.2.1
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
|
|
@ -96,7 +97,7 @@ require (
|
|||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
|
||||
|
|
@ -131,7 +132,7 @@ require (
|
|||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
|
|
|
|||
14
go.sum
14
go.sum
|
|
@ -34,8 +34,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
|||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
|
||||
|
|
@ -68,8 +68,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA
|
|||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
|
||||
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
|
||||
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M=
|
||||
|
|
@ -103,6 +103,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
|
|||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
|
|
@ -164,8 +166,8 @@ github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
|||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
|
|
|
|||
17
magefile.go
17
magefile.go
|
|
@ -447,7 +447,14 @@ func (Test) Filter(ctx context.Context, filter string) error {
|
|||
|
||||
func (Test) All() {
|
||||
mg.Deps(initVars)
|
||||
mg.Deps(Test.Feature, Test.Web, Test.E2EApi)
|
||||
mg.Deps(Test.Feature, Test.Web, Test.Caldav, Test.E2EApi)
|
||||
}
|
||||
|
||||
// Caldav runs the CalDAV protocol compliance tests in pkg/caldavtests.
|
||||
// These tests exercise the full HTTP router with WebDAV/CalDAV requests.
|
||||
func (Test) Caldav(ctx context.Context) error {
|
||||
mg.Deps(initVars)
|
||||
return runAndStreamOutput(ctx, "go", "test", goDetectVerboseFlag(), "-p", "1", "-timeout", "45m", "./pkg/caldavtests")
|
||||
}
|
||||
|
||||
// E2EApi runs the end-to-end API tests in pkg/e2etests.
|
||||
|
|
@ -1638,7 +1645,7 @@ func (Generate) ConfigYAML(commented bool) {
|
|||
|
||||
// PrepareWorktree creates a new git worktree for development.
|
||||
// The first argument is the name, which becomes both the folder name and branch name.
|
||||
// The second argument is a path to a plan file that will be copied to the new worktree (pass "" to skip).
|
||||
// The second argument is a path to a plan file that will be moved to the new worktree (pass "" to skip).
|
||||
// The worktree is created in the parent directory (../).
|
||||
// It also copies the current config.yml with an updated rootpath, and initializes the frontend.
|
||||
func (Dev) PrepareWorktree(ctx context.Context, name string, planPath string) error {
|
||||
|
|
@ -1721,10 +1728,10 @@ func (Dev) PrepareWorktree(ctx context.Context, name string, planPath string) er
|
|||
}
|
||||
|
||||
dstPlanPath := filepath.Join(plansDir, filepath.Base(planPath))
|
||||
if err := copyFile(srcPlanPath, dstPlanPath); err != nil {
|
||||
return fmt.Errorf("failed to copy plan file: %w", err)
|
||||
if err := os.Rename(srcPlanPath, dstPlanPath); err != nil {
|
||||
return fmt.Errorf("failed to move plan file: %w", err)
|
||||
}
|
||||
printSuccess("Plan file copied to %s!", dstPlanPath)
|
||||
printSuccess("Plan file moved to %s!", dstPlanPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
t.Run("Valid credentials return 200/207", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavGET(t, e, "/dav/projects/36")
|
||||
|
||||
assert.True(t, rec.Code >= 200 && rec.Code < 300,
|
||||
"Valid credentials should succeed. Got %d", rec.Code)
|
||||
})
|
||||
|
||||
t.Run("No auth returns 401", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/dav/projects/36", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code,
|
||||
"Request without auth should return 401")
|
||||
})
|
||||
|
||||
t.Run("Wrong password returns 401", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavRequest(t, e, http.MethodGet, "/dav/projects/36", "", map[string]string{
|
||||
"Authorization": basicAuthHeader(testuser15.Username, "wrongpassword"),
|
||||
})
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code,
|
||||
"Wrong password should return 401")
|
||||
})
|
||||
|
||||
t.Run("Nonexistent user returns 401", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavRequest(t, e, http.MethodGet, "/dav/projects/36", "", map[string]string{
|
||||
"Authorization": basicAuthHeader("nonexistent_user", fixturePassword),
|
||||
})
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code,
|
||||
"Nonexistent user should return 401")
|
||||
})
|
||||
|
||||
t.Run("Empty Authorization header returns 401", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/dav/projects/36", nil)
|
||||
req.Header.Set("Authorization", "")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code,
|
||||
"Empty auth header should return 401")
|
||||
})
|
||||
|
||||
t.Run("Auth on /dav/ entry point", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavRequest(t, e, "PROPFIND", "/dav/", PropfindCurrentUserPrincipal, map[string]string{
|
||||
"Depth": "0",
|
||||
})
|
||||
|
||||
// Should succeed with valid auth
|
||||
assert.True(t, rec.Code >= 200 && rec.Code < 300 || rec.Code == 207,
|
||||
"Authenticated PROPFIND on /dav/ should succeed. Got %d", rec.Code)
|
||||
})
|
||||
|
||||
t.Run("Auth on /.well-known/caldav", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Without auth
|
||||
req := httptest.NewRequest("PROPFIND", "/.well-known/caldav", strings.NewReader(PropfindCurrentUserPrincipal))
|
||||
req.Header.Set("Depth", "0")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code,
|
||||
"/.well-known/caldav without auth should return 401")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissions(t *testing.T) {
|
||||
t.Run("User cannot GET project they do not have access to", func(t *testing.T) {
|
||||
t.Skip("Known bug: CalDAV returns 500 instead of 403/404 — ErrUserDoesNotHaveAccessToProject is not recognized by caldav-go")
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// testuser1 should not be able to access project 36 (owned by user15)
|
||||
rec := caldavRequest(t, e, http.MethodGet, "/dav/projects/36", "", map[string]string{
|
||||
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
|
||||
})
|
||||
|
||||
// Should be 403 Forbidden or 404 Not Found (both are acceptable for access denial)
|
||||
assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound,
|
||||
"Unauthorized user should get 403 or 404, got %d. Body:\n%s", rec.Code, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("User cannot PUT task to project they do not have access to", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
vtodo := NewVTodo("unauthorized-task", "Should Fail").Build()
|
||||
rec := caldavRequest(t, e, http.MethodPut, "/dav/projects/36/unauthorized-task.ics", vtodo, map[string]string{
|
||||
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
})
|
||||
|
||||
assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound,
|
||||
"PUT to unauthorized project should fail with 403 or 404, got %d", rec.Code)
|
||||
})
|
||||
|
||||
t.Run("User cannot DELETE task from project they do not have access to", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Try to delete task 40 (uid-caldav-test) in project 36 as user1
|
||||
rec := caldavRequest(t, e, http.MethodDelete, "/dav/projects/36/uid-caldav-test.ics", "", map[string]string{
|
||||
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
|
||||
})
|
||||
|
||||
assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound,
|
||||
"DELETE on unauthorized project should fail with 403 or 404, got %d", rec.Code)
|
||||
})
|
||||
|
||||
t.Run("User cannot REPORT on project they do not have access to", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavRequest(t, e, "REPORT", "/dav/projects/36", ReportCalendarQuery, map[string]string{
|
||||
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
|
||||
})
|
||||
|
||||
assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound || rec.Code == 207,
|
||||
"REPORT on unauthorized project should fail or return empty, got %d", rec.Code)
|
||||
|
||||
// If it returns 207, it should have no results
|
||||
if rec.Code == 207 {
|
||||
ms := parseMultistatus(t, rec)
|
||||
assert.Empty(t, ms.Responses,
|
||||
"REPORT on unauthorized project should return empty multistatus if 207")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Project listing only shows accessible projects", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavRequest(t, e, "PROPFIND", "/dav/projects", PropfindCalendarCollectionProperties, map[string]string{
|
||||
"Depth": "1",
|
||||
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
|
||||
})
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
body := rec.Body.String()
|
||||
|
||||
// user1 should see their own projects but NOT user15's projects
|
||||
assert.NotContains(t, body, "Project 36 for Caldav tests",
|
||||
"user1 should not see user15's Project 36")
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBugs contains tests that reproduce specific bugs reported by users.
|
||||
// Each test references the GitHub issue it reproduces.
|
||||
// These tests are expected to FAIL until the bug is fixed.
|
||||
//
|
||||
// To add a new bug reproduction test:
|
||||
// 1. Create a new t.Run with the issue number in the name
|
||||
// 2. Reproduce the exact CalDAV request sequence from the bug report
|
||||
// 3. Assert what the correct behavior SHOULD be (not what it currently does)
|
||||
// 4. The test will fail until the bug is fixed — this is expected and good
|
||||
|
||||
func TestBugs(t *testing.T) {
|
||||
// Template for adding bug reproductions:
|
||||
//
|
||||
// t.Run("GitHub_Issue_NNNN_short_description", func(t *testing.T) {
|
||||
// e := setupTestEnv(t)
|
||||
//
|
||||
// // Reproduce the steps from the issue...
|
||||
// vtodo := NewVTodo("issue-NNNN", "...").Build()
|
||||
// rec := caldavPUT(t, e, "/dav/projects/36/issue-NNNN.ics", vtodo)
|
||||
//
|
||||
// // Assert the expected (correct) behavior
|
||||
// assert.Equal(t, 201, rec.Code)
|
||||
// })
|
||||
|
||||
t.Run("placeholder_no_bugs_yet", func(t *testing.T) {
|
||||
// Remove this placeholder once real bug tests are added
|
||||
t.Skip("No bug reproductions added yet")
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClientDAVx5Flow(t *testing.T) {
|
||||
t.Run("Full DAVx5 sync flow", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Step 1: Discover principal
|
||||
// DAVx5 sends PROPFIND to the server root or well-known URL
|
||||
rec := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal)
|
||||
assert.True(t, rec.Code == 207 || rec.Code == 301,
|
||||
"Step 1: PROPFIND /dav/ should return 207 or redirect. Got %d", rec.Code)
|
||||
|
||||
// Step 2: Get calendar-home-set from principal
|
||||
rec = caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet)
|
||||
assertResponseStatus(t, rec, 207)
|
||||
assert.Contains(t, rec.Body.String(), "calendar-home-set",
|
||||
"Step 2: Principal should advertise calendar-home-set")
|
||||
|
||||
// Step 3: List calendars
|
||||
rec = caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
assert.GreaterOrEqual(t, len(ms.Responses), 2,
|
||||
"Step 3: Should list calendars")
|
||||
|
||||
// Step 4: Check CTag for a specific calendar
|
||||
rec = caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties)
|
||||
assertResponseStatus(t, rec, 207)
|
||||
|
||||
// Step 5: Full sync — calendar-query to get all task ETags
|
||||
rec = caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery)
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms = parseMultistatus(t, rec)
|
||||
assert.NotEmpty(t, ms.Responses,
|
||||
"Step 5: calendar-query should return tasks")
|
||||
|
||||
// Collect hrefs for multiget
|
||||
var hrefs []string
|
||||
for _, r := range ms.Responses {
|
||||
if strings.HasSuffix(r.Href, ".ics") {
|
||||
hrefs = append(hrefs, r.Href)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Multiget to fetch specific tasks
|
||||
if len(hrefs) > 0 {
|
||||
body := ReportCalendarMultiget(hrefs[:1]...) // Just fetch first task
|
||||
rec = caldavREPORT(t, e, "/dav/projects/36", body)
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms = parseMultistatus(t, rec)
|
||||
assert.Len(t, ms.Responses, 1,
|
||||
"Step 6: multiget should return requested task")
|
||||
}
|
||||
|
||||
// Step 7: Push a local change via PUT
|
||||
vtodo := NewVTodo("davx5-sync-test", "DAVx5 Synced Task").
|
||||
Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)).
|
||||
Build()
|
||||
rec = caldavPUT(t, e, "/dav/projects/36/davx5-sync-test.ics", vtodo)
|
||||
assert.Equal(t, http.StatusCreated, rec.Code,
|
||||
"Step 7: PUT should create the task")
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientThunderbirdFlow(t *testing.T) {
|
||||
t.Run("Thunderbird discovery and initial sync", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Step 1: Thunderbird starts with OPTIONS to check DAV support
|
||||
rec := caldavOPTIONS(t, e, "/dav/")
|
||||
assert.Equal(t, http.StatusOK, rec.Code,
|
||||
"Step 1: OPTIONS should succeed")
|
||||
davHeader := rec.Header().Get("DAV")
|
||||
assert.NotEmpty(t, davHeader,
|
||||
"Step 1: Should have DAV header")
|
||||
|
||||
// Step 2: PROPFIND on well-known for principal
|
||||
rec = caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{
|
||||
"Depth": "0",
|
||||
})
|
||||
assert.True(t, rec.Code == 207 || rec.Code == 301 || rec.Code == 302,
|
||||
"Step 2: well-known should respond. Got %d", rec.Code)
|
||||
|
||||
// Step 3: PROPFIND principal for calendar-home-set
|
||||
rec = caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet)
|
||||
assertResponseStatus(t, rec, 207)
|
||||
|
||||
// Step 4: Thunderbird checks current-user-privilege-set to know if it can write
|
||||
// RFC 3744 §5.4 (rfc3744.txt line 1158)
|
||||
rec = caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCurrentUserPrivilegeSet)
|
||||
// This may return 207 with or without the property — document the behavior
|
||||
assert.True(t, rec.Code == 207 || rec.Code == 200,
|
||||
"Step 4: PROPFIND for privileges should not error. Got %d", rec.Code)
|
||||
|
||||
// Step 5: List calendars
|
||||
rec = caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
|
||||
assertResponseStatus(t, rec, 207)
|
||||
|
||||
// Step 6: Sync via calendar-query
|
||||
rec = caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery)
|
||||
assertResponseStatus(t, rec, 207)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientTasksOrgSubtasks(t *testing.T) {
|
||||
t.Run("Tasks.org subtask sync: child-only RELATED-TO", func(t *testing.T) {
|
||||
// Tasks.org behavior:
|
||||
// - Child tasks include RELATED-TO;RELTYPE=PARENT:<parent-uid>
|
||||
// - Parent tasks have NO RELATED-TO at all
|
||||
// - Tasks may arrive in any order
|
||||
// - On re-sync, parent is sent again without RELATED-TO
|
||||
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Round 1: Initial sync — parent first, then children
|
||||
parent := NewVTodo("tasks-org-parent", "Buy groceries").Build()
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/tasks-org-parent.ics", parent)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
child1 := NewVTodo("tasks-org-child-1", "Buy milk").
|
||||
RelatedToParent("tasks-org-parent").Build()
|
||||
rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-1.ics", child1)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
child2 := NewVTodo("tasks-org-child-2", "Buy eggs").
|
||||
RelatedToParent("tasks-org-parent").Build()
|
||||
rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-2.ics", child2)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
// Verify parent shows children
|
||||
rec = caldavGET(t, e, "/dav/projects/36/tasks-org-parent.ics")
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "tasks-org-child-1")
|
||||
assert.Contains(t, body, "tasks-org-child-2")
|
||||
|
||||
// Round 2: Re-sync — parent updated (title change), still no RELATED-TO
|
||||
parentUpdated := NewVTodo("tasks-org-parent", "Buy groceries (updated list)").Build()
|
||||
rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-parent.ics", parentUpdated)
|
||||
require.True(t, rec.Code >= 200 && rec.Code < 300)
|
||||
|
||||
// Verify children are still linked after parent re-sync
|
||||
rec = caldavGET(t, e, "/dav/projects/36/tasks-org-parent.ics")
|
||||
body = rec.Body.String()
|
||||
assert.Contains(t, body, "Buy groceries (updated list)",
|
||||
"Parent title should be updated")
|
||||
assert.Contains(t, body, "tasks-org-child-1",
|
||||
"Child 1 relation should survive parent re-sync")
|
||||
assert.Contains(t, body, "tasks-org-child-2",
|
||||
"Child 2 relation should survive parent re-sync")
|
||||
|
||||
// Round 3: Complete child via PUT with STATUS:COMPLETED
|
||||
child1Done := NewVTodo("tasks-org-child-1", "Buy milk").
|
||||
RelatedToParent("tasks-org-parent").
|
||||
Status("COMPLETED").
|
||||
Completed(time.Now().UTC()).
|
||||
Build()
|
||||
rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-1.ics", child1Done)
|
||||
require.True(t, rec.Code >= 200 && rec.Code < 300)
|
||||
|
||||
// Verify child is completed
|
||||
rec = caldavGET(t, e, "/dav/projects/36/tasks-org-child-1.ics")
|
||||
assert.Contains(t, rec.Body.String(), "STATUS:COMPLETED")
|
||||
})
|
||||
|
||||
t.Run("Tasks.org subtask sync: children arrive before parent", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Children arrive first (reverse order)
|
||||
child := NewVTodo("tasks-rev-child", "Subtask").
|
||||
RelatedToParent("tasks-rev-parent").Build()
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/tasks-rev-child.ics", child)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
// Parent arrives later — no RELATED-TO
|
||||
parent := NewVTodo("tasks-rev-parent", "Main Task").Build()
|
||||
rec = caldavPUT(t, e, "/dav/projects/36/tasks-rev-parent.ics", parent)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
// Verify bidirectional relations
|
||||
rec = caldavGET(t, e, "/dav/projects/36/tasks-rev-parent.ics")
|
||||
assert.Contains(t, rec.Body.String(), "SUMMARY:Main Task",
|
||||
"Parent should have real title, not DUMMY")
|
||||
assert.Contains(t, rec.Body.String(), "tasks-rev-child",
|
||||
"Parent should show child relation")
|
||||
|
||||
rec = caldavGET(t, e, "/dav/projects/36/tasks-rev-child.ics")
|
||||
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:tasks-rev-parent")
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCRUDCreate(t *testing.T) {
|
||||
// RFC 4791 §5.3.2 (rfc4791.txt line 1358):
|
||||
// "A PUT request on a calendar collection creates a new calendar
|
||||
// object resource when the Request-URI does not identify an
|
||||
// existing resource."
|
||||
|
||||
t.Run("PUT new task returns 201 Created", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
vtodo := NewVTodo("test-create-uid", "Test Create Task").
|
||||
Due(time.Date(2024, 3, 1, 15, 0, 0, 0, time.UTC)).
|
||||
Build()
|
||||
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/test-create-uid.ics", vtodo)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, rec.Code,
|
||||
"PUT of new resource should return 201. Body:\n%s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("PUT new task sets ETag in response", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
vtodo := NewVTodo("test-etag-uid", "Test ETag Task").Build()
|
||||
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/test-etag-uid.ics", vtodo)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
etag := rec.Header().Get("ETag")
|
||||
assert.NotEmpty(t, etag,
|
||||
"PUT response should include ETag header for the newly created resource")
|
||||
})
|
||||
|
||||
t.Run("Created task is retrievable via GET", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
vtodo := NewVTodo("test-roundtrip-uid", "Roundtrip Test Task").
|
||||
Description("A task created via CalDAV PUT").
|
||||
Priority(3).
|
||||
Build()
|
||||
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/test-roundtrip-uid.ics", vtodo)
|
||||
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
// Now GET the task back
|
||||
rec2 := caldavGET(t, e, "/dav/projects/36/test-roundtrip-uid.ics")
|
||||
assert.Equal(t, http.StatusOK, rec2.Code)
|
||||
|
||||
body := rec2.Body.String()
|
||||
assert.Contains(t, body, "BEGIN:VCALENDAR")
|
||||
assert.Contains(t, body, "BEGIN:VTODO")
|
||||
assert.Contains(t, body, "UID:test-roundtrip-uid")
|
||||
assert.Contains(t, body, "SUMMARY:Roundtrip Test Task")
|
||||
})
|
||||
|
||||
t.Run("PUT with invalid VCALENDAR returns error", func(t *testing.T) {
|
||||
t.Skip("Known bug: parse errors propagate as 500 instead of 400 — caldav-go does not map parse failures to 4xx")
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/bad-task.ics", "not a valid vcalendar")
|
||||
|
||||
// Should fail with a 4xx error
|
||||
assert.GreaterOrEqual(t, rec.Code, 400,
|
||||
"PUT with invalid VCALENDAR should return 4xx error")
|
||||
assert.Less(t, rec.Code, 500,
|
||||
"PUT with invalid VCALENDAR should not be a server error")
|
||||
})
|
||||
|
||||
t.Run("PUT to nonexistent project returns 404", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
vtodo := NewVTodo("test-noproject-uid", "No Project Task").Build()
|
||||
rec := caldavPUT(t, e, "/dav/projects/99999/test-noproject-uid.ics", vtodo)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"PUT to nonexistent project should return 404")
|
||||
})
|
||||
|
||||
t.Run("PUT task with all supported fields", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
vtodo := NewVTodo("test-allfields-uid", "All Fields Task").
|
||||
Description("Full description\\nwith newlines").
|
||||
Priority(1). // Highest priority in CalDAV
|
||||
Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)).
|
||||
DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)).
|
||||
Categories("work", "urgent").
|
||||
Status("IN-PROCESS").
|
||||
AlarmAbsolute(time.Date(2024, 6, 15, 8, 0, 0, 0, time.UTC)).
|
||||
Build()
|
||||
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/test-allfields-uid.ics", vtodo)
|
||||
assert.Equal(t, http.StatusCreated, rec.Code,
|
||||
"PUT with all fields should succeed. Body:\n%s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestCRUDRead(t *testing.T) {
|
||||
t.Run("GET existing task returns VCALENDAR", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Task 40 (uid-caldav-test) exists in project 36 from fixtures
|
||||
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "BEGIN:VCALENDAR")
|
||||
assert.Contains(t, body, "BEGIN:VTODO")
|
||||
assert.Contains(t, body, "UID:uid-caldav-test")
|
||||
assert.Contains(t, body, "SUMMARY:Title Caldav Test")
|
||||
assert.Contains(t, body, "END:VTODO")
|
||||
assert.Contains(t, body, "END:VCALENDAR")
|
||||
})
|
||||
|
||||
t.Run("GET returns correct Content-Type", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
// Should be text/calendar per RFC 4791
|
||||
assert.Contains(t, contentType, "text/calendar",
|
||||
"GET on .ics resource should return Content-Type: text/calendar, got: %s", contentType)
|
||||
})
|
||||
|
||||
t.Run("GET returns ETag header", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
etag := rec.Header().Get("ETag")
|
||||
assert.NotEmpty(t, etag, "GET response should include ETag header")
|
||||
})
|
||||
|
||||
t.Run("GET nonexistent task returns 404", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavGET(t, e, "/dav/projects/36/nonexistent-uid.ics")
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"GET nonexistent task should return 404")
|
||||
})
|
||||
|
||||
t.Run("GET project returns all tasks as VCALENDAR", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavGET(t, e, "/dav/projects/36")
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "BEGIN:VCALENDAR")
|
||||
assert.Contains(t, body, "X-WR-CALNAME:Project 36 for Caldav tests")
|
||||
// Should contain multiple VTODOs
|
||||
assert.Contains(t, body, "uid-caldav-test")
|
||||
})
|
||||
|
||||
t.Run("GET task with .ics suffix works", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
})
|
||||
|
||||
t.Run("GET task without .ics suffix works", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Some clients may not include .ics suffix
|
||||
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test")
|
||||
// This might 404 depending on implementation — document the behavior
|
||||
// Either 200 or 404 is acceptable, but should be consistent
|
||||
assert.True(t, rec.Code == http.StatusOK || rec.Code == http.StatusNotFound,
|
||||
"GET without .ics should return 200 or 404, got %d", rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCRUDUpdate(t *testing.T) {
|
||||
t.Run("PUT to existing task updates it", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// First create
|
||||
vtodo := NewVTodo("test-update-uid", "Original Title").Build()
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/test-update-uid.ics", vtodo)
|
||||
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
// Then update
|
||||
vtodoUpdated := NewVTodo("test-update-uid", "Updated Title").
|
||||
Description("Now with a description").
|
||||
Build()
|
||||
rec2 := caldavPUT(t, e, "/dav/projects/36/test-update-uid.ics", vtodoUpdated)
|
||||
|
||||
// Update should return 200 or 204 (not 201)
|
||||
assert.True(t, rec2.Code == http.StatusOK ||
|
||||
rec2.Code == http.StatusNoContent ||
|
||||
rec2.Code == http.StatusCreated, // Some implementations return 201 for updates too
|
||||
"PUT update should return 200, 204, or 201, got %d", rec2.Code)
|
||||
|
||||
// Verify the update took effect
|
||||
rec3 := caldavGET(t, e, "/dav/projects/36/test-update-uid.ics")
|
||||
assert.Equal(t, http.StatusOK, rec3.Code)
|
||||
assert.Contains(t, rec3.Body.String(), "Updated Title")
|
||||
assert.Contains(t, rec3.Body.String(), "Now with a description")
|
||||
})
|
||||
|
||||
t.Run("PUT update changes ETag", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create
|
||||
vtodo := NewVTodo("test-etag-change-uid", "ETag Change Test").Build()
|
||||
rec1 := caldavPUT(t, e, "/dav/projects/36/test-etag-change-uid.ics", vtodo)
|
||||
assert.Equal(t, http.StatusCreated, rec1.Code)
|
||||
etag1 := rec1.Header().Get("ETag")
|
||||
|
||||
// ETag uses second-precision timestamps, so we must wait to ensure a different value
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// Update
|
||||
vtodoUpdated := NewVTodo("test-etag-change-uid", "ETag Change Test Updated").Build()
|
||||
rec2 := caldavPUT(t, e, "/dav/projects/36/test-etag-change-uid.ics", vtodoUpdated)
|
||||
etag2 := rec2.Header().Get("ETag")
|
||||
|
||||
// ETags should differ after update
|
||||
if etag1 != "" && etag2 != "" {
|
||||
assert.NotEqual(t, etag1, etag2,
|
||||
"ETag should change after update. Before: %s, After: %s", etag1, etag2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUT update preserves UID", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create
|
||||
vtodo := NewVTodo("test-preserve-uid", "Preserve UID Test").Build()
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/test-preserve-uid.ics", vtodo)
|
||||
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
// Update with different title but same UID
|
||||
vtodoUpdated := NewVTodo("test-preserve-uid", "Updated Preserve UID").Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/test-preserve-uid.ics", vtodoUpdated)
|
||||
|
||||
// Verify UID is preserved
|
||||
rec3 := caldavGET(t, e, "/dav/projects/36/test-preserve-uid.ics")
|
||||
assert.Contains(t, rec3.Body.String(), "UID:test-preserve-uid")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCRUDDelete(t *testing.T) {
|
||||
t.Run("DELETE existing task returns 204", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Task 40 (uid-caldav-test) exists in project 36
|
||||
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics")
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code,
|
||||
"DELETE should return 204 No Content. Body:\n%s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("DELETE task makes it unreachable", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Delete task 40
|
||||
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics")
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// Try to GET it — should 404
|
||||
rec2 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
|
||||
assert.Equal(t, http.StatusNotFound, rec2.Code,
|
||||
"GET after DELETE should return 404")
|
||||
})
|
||||
|
||||
t.Run("DELETE nonexistent task returns 404", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavDELETE(t, e, "/dav/projects/36/nonexistent-uid.ics")
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"DELETE nonexistent task should return 404")
|
||||
})
|
||||
|
||||
t.Run("DELETE task removes it from project listing", func(t *testing.T) {
|
||||
t.Skip("Known bug: DeleteResource relies on GetResource being called first to populate task ID — delete silently fails")
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// First verify task exists in project listing
|
||||
rec := caldavGET(t, e, "/dav/projects/36")
|
||||
assert.Contains(t, rec.Body.String(), "uid-caldav-test")
|
||||
|
||||
// Delete it
|
||||
caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics")
|
||||
|
||||
// Verify it's gone from the listing
|
||||
rec2 := caldavGET(t, e, "/dav/projects/36")
|
||||
assert.NotContains(t, rec2.Body.String(), "uid-caldav-test")
|
||||
})
|
||||
|
||||
t.Run("Full lifecycle: PUT create -> GET read -> PUT update -> DELETE", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
uid := "test-lifecycle-uid"
|
||||
path := "/dav/projects/36/" + uid + ".ics"
|
||||
|
||||
// Create
|
||||
vtodo := NewVTodo(uid, "Lifecycle Test").Build()
|
||||
rec := caldavPUT(t, e, path, vtodo)
|
||||
assert.Equal(t, http.StatusCreated, rec.Code, "Create failed")
|
||||
|
||||
// Read
|
||||
rec = caldavGET(t, e, path)
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "Read failed")
|
||||
assert.Contains(t, rec.Body.String(), "Lifecycle Test")
|
||||
|
||||
// Update
|
||||
vtodo2 := NewVTodo(uid, "Lifecycle Test Updated").Build()
|
||||
rec = caldavPUT(t, e, path, vtodo2)
|
||||
assert.True(t, rec.Code >= 200 && rec.Code < 300, "Update failed with %d", rec.Code)
|
||||
|
||||
// Verify update
|
||||
rec = caldavGET(t, e, path)
|
||||
assert.Contains(t, rec.Body.String(), "Lifecycle Test Updated")
|
||||
|
||||
// Delete
|
||||
rec = caldavDELETE(t, e, path)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code, "Delete failed")
|
||||
|
||||
// Verify gone
|
||||
rec = caldavGET(t, e, path)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "Task should be gone after delete")
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDiscovery(t *testing.T) {
|
||||
// RFC 6764 §5 (rfc6764.txt line 205):
|
||||
// "A CalDAV server SHOULD provide a well-known URI that redirects
|
||||
// to the context path of the CalDAV service."
|
||||
|
||||
t.Run("well-known/caldav responds", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{
|
||||
"Depth": "0",
|
||||
})
|
||||
|
||||
// Should get either a redirect (301/302) or a 207 with principal info
|
||||
// Both are acceptable per RFC 6764 §5
|
||||
assert.True(t,
|
||||
rec.Code == http.StatusMovedPermanently ||
|
||||
rec.Code == http.StatusFound ||
|
||||
rec.Code == http.StatusMultiStatus,
|
||||
"Expected 301, 302, or 207 from /.well-known/caldav, got %d. Body:\n%s", rec.Code, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("well-known/caldav/ with trailing slash responds", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavRequest(t, e, "PROPFIND", "/.well-known/caldav/", PropfindCurrentUserPrincipal, map[string]string{
|
||||
"Depth": "0",
|
||||
})
|
||||
|
||||
assert.True(t,
|
||||
rec.Code == http.StatusMovedPermanently ||
|
||||
rec.Code == http.StatusFound ||
|
||||
rec.Code == http.StatusMultiStatus,
|
||||
"Expected 301, 302, or 207 from /.well-known/caldav/, got %d. Body:\n%s", rec.Code, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("well-known/caldav without auth returns 401", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest("PROPFIND", "/.well-known/caldav", strings.NewReader(PropfindCurrentUserPrincipal))
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "0")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code,
|
||||
"CalDAV well-known endpoint should require authentication")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscoveryPrincipal(t *testing.T) {
|
||||
// RFC 5397 §3 (rfc5397.txt line 126):
|
||||
// "This property contains a URL that identifies the principal resource
|
||||
// corresponding to the currently authenticated user."
|
||||
|
||||
t.Run("PROPFIND on /dav/ returns current-user-principal", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal)
|
||||
|
||||
// Should get 207 Multi-Status
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
assert.NotEmpty(t, ms.Responses, "Multistatus should contain at least one response")
|
||||
|
||||
// The current-user-principal should point to a principal resource
|
||||
// containing the username
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "current-user-principal",
|
||||
"Response should contain current-user-principal property")
|
||||
// Should contain the username in the principal URL
|
||||
assert.Contains(t, body, "user15",
|
||||
"Principal URL should contain the authenticated username")
|
||||
})
|
||||
|
||||
t.Run("PROPFIND on /dav/principals/user15/ returns principal info", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
|
||||
body := rec.Body.String()
|
||||
// Per RFC 4791 §6.2.1, the principal should advertise calendar-home-set
|
||||
assert.Contains(t, body, "calendar-home-set",
|
||||
"Principal resource should include calendar-home-set property")
|
||||
// The home set should point to /dav/projects
|
||||
assert.Contains(t, body, "/dav/projects",
|
||||
"calendar-home-set should point to /dav/projects")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscoveryCalendarHome(t *testing.T) {
|
||||
// RFC 4791 §6.2.1 (rfc4791.txt line 1651):
|
||||
// "The calendar-home-set property identifies the URL of any
|
||||
// WebDAV collections that contain calendar collections owned
|
||||
// by the associated principal resource."
|
||||
|
||||
t.Run("PROPFIND Depth:1 on /dav/projects lists calendars", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
// testuser15 owns projects 36 and 38 (from fixtures)
|
||||
// The response should include at least these projects
|
||||
assert.GreaterOrEqual(t, len(ms.Responses), 2,
|
||||
"Should list at least the 2 projects owned by testuser15")
|
||||
|
||||
// Each response should have an href and a displayname
|
||||
for _, r := range ms.Responses {
|
||||
assert.NotEmpty(t, r.Href, "Each calendar response should have an href")
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
// Check that the projects we know about are listed
|
||||
assert.Contains(t, body, "Project 36 for Caldav tests",
|
||||
"Should list Project 36")
|
||||
assert.Contains(t, body, "Project 38 for Caldav tests",
|
||||
"Should list Project 38")
|
||||
})
|
||||
|
||||
t.Run("PROPFIND Depth:0 on /dav/projects returns just the home collection", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects", "0", PropfindCalendarCollectionProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
// Depth 0 should return just the collection itself, not children
|
||||
assert.Len(t, ms.Responses, 1,
|
||||
"Depth 0 PROPFIND should return only the collection itself")
|
||||
})
|
||||
|
||||
t.Run("Each listed calendar has resourcetype with calendar", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
body := rec.Body.String()
|
||||
|
||||
// Per RFC 4791 §5.2, calendar collections MUST report
|
||||
// DAV:collection and CALDAV:calendar in resourcetype
|
||||
assert.Contains(t, body, "calendar",
|
||||
"Calendar collections should have calendar in resourcetype")
|
||||
})
|
||||
|
||||
t.Run("Each listed calendar has supported-calendar-component-set", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
body := rec.Body.String()
|
||||
|
||||
// Per RFC 4791 §5.2.3 (rfc4791.txt line 768), calendar collections
|
||||
// SHOULD report supported-calendar-component-set
|
||||
assert.Contains(t, body, "VTODO",
|
||||
"supported-calendar-component-set should include VTODO")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscoveryOPTIONS(t *testing.T) {
|
||||
// RFC 4791 §5.1 (rfc4791.txt line 602):
|
||||
// "A CalDAV server MUST include 'calendar-access' as a field in the
|
||||
// DAV response header from an OPTIONS request on any resource that
|
||||
// supports the CalDAV extensions."
|
||||
|
||||
t.Run("OPTIONS on /dav/ returns DAV header with calendar-access", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavOPTIONS(t, e, "/dav/")
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
davHeader := rec.Header().Get("DAV")
|
||||
assert.NotEmpty(t, davHeader, "OPTIONS response should include DAV header")
|
||||
assert.Contains(t, davHeader, "calendar-access",
|
||||
"DAV header should include 'calendar-access' per RFC 4791 §5.1")
|
||||
})
|
||||
|
||||
t.Run("OPTIONS on /dav/projects/36 returns DAV header", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavOPTIONS(t, e, "/dav/projects/36")
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
davHeader := rec.Header().Get("DAV")
|
||||
assert.NotEmpty(t, davHeader, "OPTIONS response should include DAV header")
|
||||
})
|
||||
|
||||
t.Run("OPTIONS on /dav/ returns Allow header with supported methods", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavOPTIONS(t, e, "/dav/")
|
||||
|
||||
allowHeader := rec.Header().Get("Allow")
|
||||
// A CalDAV server should advertise at least these methods
|
||||
for _, method := range []string{"OPTIONS", "GET", "PUT", "DELETE", "PROPFIND", "REPORT"} {
|
||||
assert.Contains(t, allowHeader, method,
|
||||
"Allow header should include %s", method)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscoveryFullChain(t *testing.T) {
|
||||
// RFC 6764 §6 (rfc6764.txt line 254) describes the full bootstrapping flow:
|
||||
// 1. Client does PROPFIND on /.well-known/caldav (or follows redirect)
|
||||
// 2. Client extracts current-user-principal from response
|
||||
// 3. Client does PROPFIND on principal URL for calendar-home-set
|
||||
// 4. Client does PROPFIND Depth:1 on calendar-home-set to list calendars
|
||||
|
||||
t.Run("Full discovery chain: well-known -> principal -> home -> calendars", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Step 1: Hit well-known endpoint
|
||||
rec1 := caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{
|
||||
"Depth": "0",
|
||||
})
|
||||
// Accept either redirect or direct response
|
||||
assert.True(t, rec1.Code == 207 || rec1.Code == 301 || rec1.Code == 302,
|
||||
"Step 1: /.well-known/caldav should respond with 207, 301, or 302, got %d", rec1.Code)
|
||||
|
||||
// Step 2: PROPFIND the entry point for principal info
|
||||
rec2 := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal)
|
||||
assertResponseStatus(t, rec2, 207)
|
||||
|
||||
// Step 3: PROPFIND the principal URL for calendar-home-set
|
||||
// The principal URL for testuser15 should be /dav/principals/user15/
|
||||
rec3 := caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet)
|
||||
assertResponseStatus(t, rec3, 207)
|
||||
|
||||
body3 := rec3.Body.String()
|
||||
assert.Contains(t, body3, "calendar-home-set",
|
||||
"Step 3: Principal should advertise calendar-home-set")
|
||||
assert.Contains(t, body3, "/dav/projects",
|
||||
"Step 3: calendar-home-set should point to /dav/projects")
|
||||
|
||||
// Step 4: PROPFIND Depth:1 on calendar home to list calendars
|
||||
rec4 := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
|
||||
assertResponseStatus(t, rec4, 207)
|
||||
|
||||
ms4 := parseMultistatus(t, rec4)
|
||||
assert.GreaterOrEqual(t, len(ms4.Responses), 2,
|
||||
"Step 4: Should list at least 2 calendars for testuser15")
|
||||
|
||||
body4 := rec4.Body.String()
|
||||
assert.Contains(t, body4, "Project 36 for Caldav tests",
|
||||
"Step 4: Should list Project 36")
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/keyvalue"
|
||||
"code.vikunja.io/api/pkg/routes"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// These are the test users, the same way they are in the test database
|
||||
var (
|
||||
testuser1 = user.User{ //nolint:gosec // test fixture credentials
|
||||
ID: 1,
|
||||
Username: "user1",
|
||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||
Email: "user1@example.com",
|
||||
Issuer: "local",
|
||||
}
|
||||
testuser15 = user.User{ //nolint:gosec // test fixture credentials
|
||||
ID: 15,
|
||||
Username: "user15",
|
||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||
Email: "user15@example.com",
|
||||
Issuer: "local",
|
||||
}
|
||||
)
|
||||
|
||||
// fixturePassword is the plaintext password for all test fixture users
|
||||
const fixturePassword = "12345678"
|
||||
|
||||
func setupTestEnv(t *testing.T) *echo.Echo {
|
||||
t.Helper()
|
||||
|
||||
config.InitDefaultConfig()
|
||||
config.ServicePublicURL.Set("https://localhost")
|
||||
|
||||
log.InitLogger()
|
||||
files.InitTests()
|
||||
user.InitTests()
|
||||
models.SetupTests()
|
||||
events.Fake()
|
||||
keyvalue.InitStorage()
|
||||
|
||||
err := db.LoadFixtures()
|
||||
require.NoError(t, err)
|
||||
|
||||
e := routes.NewEcho()
|
||||
routes.RegisterRoutes(e)
|
||||
return e
|
||||
}
|
||||
|
||||
// basicAuthHeader returns the Authorization header value for HTTP Basic Auth.
|
||||
func basicAuthHeader(username, password string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
||||
}
|
||||
|
||||
// caldavRequest sends an HTTP request through the full Echo router and returns the response.
|
||||
func caldavRequest(t *testing.T, e *echo.Echo, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
|
||||
// Default to testuser15 basic auth (the caldav test user) unless overridden
|
||||
if _, hasAuth := headers["Authorization"]; !hasAuth {
|
||||
req.Header.Set("Authorization", basicAuthHeader(testuser15.Username, fixturePassword))
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// caldavPROPFIND sends a PROPFIND request.
|
||||
func caldavPROPFIND(t *testing.T, e *echo.Echo, path, depth, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return caldavRequest(t, e, "PROPFIND", path, body, map[string]string{
|
||||
"Depth": depth,
|
||||
})
|
||||
}
|
||||
|
||||
// caldavREPORT sends a REPORT request.
|
||||
func caldavREPORT(t *testing.T, e *echo.Echo, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return caldavRequest(t, e, "REPORT", path, body, nil)
|
||||
}
|
||||
|
||||
// caldavGET sends a GET request.
|
||||
func caldavGET(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return caldavRequest(t, e, http.MethodGet, path, "", nil)
|
||||
}
|
||||
|
||||
// caldavPUT sends a PUT request with iCalendar content.
|
||||
func caldavPUT(t *testing.T, e *echo.Echo, path, vcalendar string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return caldavRequest(t, e, http.MethodPut, path, vcalendar, map[string]string{
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
})
|
||||
}
|
||||
|
||||
// caldavDELETE sends a DELETE request.
|
||||
func caldavDELETE(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return caldavRequest(t, e, http.MethodDelete, path, "", nil)
|
||||
}
|
||||
|
||||
// caldavOPTIONS sends an OPTIONS request.
|
||||
func caldavOPTIONS(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return caldavRequest(t, e, http.MethodOptions, path, "", nil)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if testing.Short() {
|
||||
println("-short requested, skipping long-running caldav tests")
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
// PROPFIND request bodies used by CalDAV clients.
|
||||
|
||||
// PropfindCurrentUserPrincipal requests the current-user-principal property.
|
||||
// RFC 5397 §3
|
||||
const PropfindCurrentUserPrincipal = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:current-user-principal/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindCalendarHomeSet requests the calendar-home-set property.
|
||||
// RFC 4791 §6.2.1
|
||||
const PropfindCalendarHomeSet = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<C:calendar-home-set/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindCalendarCollectionProperties requests common calendar collection properties.
|
||||
// RFC 4791 §5.2
|
||||
const PropfindCalendarCollectionProperties = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:IC="http://apple.com/ns/ical/">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:resourcetype/>
|
||||
<D:getetag/>
|
||||
<CS:getctag/>
|
||||
<C:supported-calendar-component-set/>
|
||||
<C:calendar-description/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindResourceProperties requests properties of a calendar resource (task).
|
||||
const PropfindResourceProperties = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindAllProps requests all properties (allprop).
|
||||
// RFC 4918 §9.1
|
||||
const PropfindAllProps = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindCurrentUserPrivilegeSet requests the current-user-privilege-set property.
|
||||
// RFC 3744 §5.4
|
||||
const PropfindCurrentUserPrivilegeSet = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:current-user-privilege-set/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// ReportCalendarQuery is a calendar-query REPORT requesting all VTODOs.
|
||||
// RFC 4791 §7.8
|
||||
const ReportCalendarQuery = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VTODO"/>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>`
|
||||
|
||||
// ReportCalendarMultiget builds a calendar-multiget REPORT for specific hrefs.
|
||||
// RFC 4791 §7.9
|
||||
func ReportCalendarMultiget(hrefs ...string) string {
|
||||
var hrefXML string
|
||||
for _, href := range hrefs {
|
||||
hrefXML += " <D:href>" + href + "</D:href>\n"
|
||||
}
|
||||
return `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-multiget xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
` + hrefXML + `</C:calendar-multiget>`
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ics "github.com/arran4/golang-ical"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPropfindCollection(t *testing.T) {
|
||||
// RFC 4918 §9.1 (rfc4918.txt line 1939):
|
||||
// "The PROPFIND method retrieves properties defined on the resource
|
||||
// identified by the Request-URI."
|
||||
|
||||
t.Run("Depth 0 on project returns collection properties", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
// Depth 0 should return exactly 1 response (the collection itself)
|
||||
assert.Len(t, ms.Responses, 1,
|
||||
"Depth 0 should return exactly the collection")
|
||||
|
||||
r := ms.Responses[0]
|
||||
prop := getSuccessfulProp(t, r)
|
||||
|
||||
// displayname should be the project title
|
||||
assert.Equal(t, "Project 36 for Caldav tests", prop.DisplayName,
|
||||
"displayname should match project title")
|
||||
|
||||
// resourcetype should include both DAV:collection and CALDAV:calendar
|
||||
assert.Contains(t, prop.ResourceType.InnerXML, "collection",
|
||||
"resourcetype should include DAV:collection")
|
||||
})
|
||||
|
||||
t.Run("Depth 1 on project returns collection plus tasks", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindCalendarCollectionProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
// Project 36 has 5 tasks in fixtures (tasks 40-43, 45)
|
||||
// Depth 1 should return the collection + all tasks = 6 responses
|
||||
assert.GreaterOrEqual(t, len(ms.Responses), 6,
|
||||
"Depth 1 should return collection + all tasks")
|
||||
|
||||
// First response should be the collection itself
|
||||
// Subsequent responses should be individual tasks
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, ".ics",
|
||||
"Task responses should have .ics hrefs")
|
||||
})
|
||||
|
||||
t.Run("Depth 1 on project returns ETags for each resource", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindResourceProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
for _, r := range ms.Responses {
|
||||
prop := getSuccessfulProp(t, r)
|
||||
// Every resource should have an ETag
|
||||
// RFC 4918 §15.6: "strong ETags MUST be used"
|
||||
assert.NotEmpty(t, prop.GetETag,
|
||||
"Every resource in PROPFIND should have an ETag. Href: %s", r.Href)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PROPFIND on nonexistent project returns 404", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects/99999", "0", PropfindCalendarCollectionProperties)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"PROPFIND on nonexistent project should return 404")
|
||||
})
|
||||
|
||||
t.Run("Depth 1 includes calendar-data for each task", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindResourceProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
taskCount := 0
|
||||
for _, r := range ms.Responses {
|
||||
prop := getSuccessfulProp(t, r)
|
||||
if prop.CalendarData != "" {
|
||||
taskCount++
|
||||
// Each calendar-data should be valid iCalendar
|
||||
cal := parseICalFromString(t, prop.CalendarData)
|
||||
vtodo := getVTodo(t, cal)
|
||||
uid := getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId)
|
||||
assert.NotEmpty(t, uid, "Each VTODO should have a UID")
|
||||
}
|
||||
}
|
||||
assert.Positive(t, taskCount, "Should have at least one task with calendar-data")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPropfindResource(t *testing.T) {
|
||||
t.Run("Depth 0 on task returns task properties", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Task 40 has UID "uid-caldav-test" in project 36
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
assert.Len(t, ms.Responses, 1,
|
||||
"Depth 0 on a task should return exactly 1 response")
|
||||
|
||||
r := ms.Responses[0]
|
||||
prop := getSuccessfulProp(t, r)
|
||||
|
||||
assert.NotEmpty(t, prop.GetETag, "Task should have an ETag")
|
||||
assert.NotEmpty(t, prop.CalendarData, "Task should have calendar-data")
|
||||
|
||||
// Parse and validate the calendar data
|
||||
cal := parseICalFromString(t, prop.CalendarData)
|
||||
vtodo := getVTodo(t, cal)
|
||||
assert.Equal(t, "uid-caldav-test", getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId))
|
||||
assert.Equal(t, "Title Caldav Test", getVTodoProperty(vtodo, ics.ComponentPropertySummary))
|
||||
})
|
||||
|
||||
t.Run("PROPFIND on nonexistent task returns 404", func(t *testing.T) {
|
||||
t.Skip("Known limitation: caldav-go returns 207 with 404 propstat instead of top-level 404")
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects/36/nonexistent-uid.ics", "0", PropfindResourceProperties)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"PROPFIND on nonexistent task should return 404")
|
||||
})
|
||||
|
||||
t.Run("ETag format is quoted string", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
r := ms.Responses[0]
|
||||
prop := getSuccessfulProp(t, r)
|
||||
|
||||
// RFC 4918 requires ETags to be quoted strings
|
||||
assert.True(t, len(prop.GetETag) > 2 &&
|
||||
prop.GetETag[0] == '"' && prop.GetETag[len(prop.GetETag)-1] == '"',
|
||||
"ETag should be a quoted string, got: %s", prop.GetETag)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPropfindCalendarHome(t *testing.T) {
|
||||
t.Run("Depth 1 on /dav/projects lists all accessible calendars", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
// testuser15 should see at least projects 36 and 38
|
||||
projectFound36 := false
|
||||
projectFound38 := false
|
||||
for _, r := range ms.Responses {
|
||||
if strings.Contains(r.Href, "36") {
|
||||
projectFound36 = true
|
||||
}
|
||||
if strings.Contains(r.Href, "38") {
|
||||
projectFound38 = true
|
||||
}
|
||||
}
|
||||
assert.True(t, projectFound36, "Should list project 36 in calendar home")
|
||||
assert.True(t, projectFound38, "Should list project 38 in calendar home")
|
||||
})
|
||||
|
||||
t.Run("Each calendar has displayname matching project title", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties)
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
ms := parseMultistatus(t, rec)
|
||||
|
||||
for _, r := range ms.Responses {
|
||||
prop := getSuccessfulProp(t, r)
|
||||
if prop.DisplayName != "" {
|
||||
// Every calendar with a displayname should have a reasonable title
|
||||
assert.NotEmpty(t, prop.DisplayName,
|
||||
"Calendar at %s should have a displayname", r.Href)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("User only sees projects they have access to", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// testuser1 should NOT see testuser15's projects (36, 38)
|
||||
rec := caldavRequest(t, e, "PROPFIND", "/dav/projects", PropfindCalendarCollectionProperties, map[string]string{
|
||||
"Depth": "1",
|
||||
"Authorization": basicAuthHeader(testuser1.Username, fixturePassword),
|
||||
})
|
||||
|
||||
assertResponseStatus(t, rec, 207)
|
||||
|
||||
body := rec.Body.String()
|
||||
// user1 should not see project 36 or 38 (owned by user15)
|
||||
assert.NotContains(t, body, "Project 36 for Caldav tests",
|
||||
"user1 should not see user15's project 36")
|
||||
assert.NotContains(t, body, "Project 38 for Caldav tests",
|
||||
"user1 should not see user15's project 38")
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRelationsBasic(t *testing.T) {
|
||||
// RFC 5545 §3.8.4.5 (rfc5545.txt line 6391):
|
||||
// "This property is used to represent a relationship or reference
|
||||
// between one calendar component and another."
|
||||
|
||||
t.Run("Parent with RELTYPE=CHILD and child with RELTYPE=PARENT", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create parent (no relations)
|
||||
parent := NewVTodo("rel-parent-1", "Parent Task").Build()
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/rel-parent-1.ics", parent)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
// Create child referencing parent
|
||||
child := NewVTodo("rel-child-1", "Child Task").
|
||||
RelatedToParent("rel-parent-1").
|
||||
Build()
|
||||
rec = caldavPUT(t, e, "/dav/projects/36/rel-child-1.ics", child)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
// GET child — should have RELATED-TO;RELTYPE=PARENT
|
||||
rec = caldavGET(t, e, "/dav/projects/36/rel-child-1.ics")
|
||||
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:rel-parent-1",
|
||||
"Child should have RELATED-TO pointing to parent")
|
||||
|
||||
// GET parent — should have RELATED-TO;RELTYPE=CHILD (inverse)
|
||||
rec = caldavGET(t, e, "/dav/projects/36/rel-parent-1.ics")
|
||||
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:rel-child-1",
|
||||
"Parent should have inverse RELATED-TO pointing to child")
|
||||
})
|
||||
|
||||
t.Run("Grandchild chain: parent -> child -> grandchild", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create in order: parent, child, grandchild
|
||||
parent := NewVTodo("rel-gp-parent", "Grandparent").Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/rel-gp-parent.ics", parent)
|
||||
|
||||
child := NewVTodo("rel-gp-child", "Parent").
|
||||
RelatedToParent("rel-gp-parent").
|
||||
Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/rel-gp-child.ics", child)
|
||||
|
||||
grandchild := NewVTodo("rel-gp-grandchild", "Child").
|
||||
RelatedToParent("rel-gp-child").
|
||||
Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/rel-gp-grandchild.ics", grandchild)
|
||||
|
||||
// Verify middle node has both parent and child relations
|
||||
rec := caldavGET(t, e, "/dav/projects/36/rel-gp-child.ics")
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "RELATED-TO;RELTYPE=PARENT:rel-gp-parent")
|
||||
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rel-gp-grandchild")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelationsReverseOrder(t *testing.T) {
|
||||
t.Run("Child arrives before parent (Tasks.org pattern)", func(t *testing.T) {
|
||||
// This is the most common real-world scenario:
|
||||
// Tasks.org sends child with RELATED-TO;RELTYPE=PARENT but the parent
|
||||
// has NO RELATED-TO at all. The child may arrive before the parent.
|
||||
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Step 1: Child arrives first
|
||||
child := NewVTodo("rev-child-first", "Child First").
|
||||
RelatedToParent("rev-parent-late").
|
||||
Build()
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/rev-child-first.ics", child)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
// Step 2: Parent arrives later (no RELATED-TO)
|
||||
parent := NewVTodo("rev-parent-late", "Parent Late").Build()
|
||||
rec = caldavPUT(t, e, "/dav/projects/36/rev-parent-late.ics", parent)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
// Step 3: Verify parent has correct title (not DUMMY-UID)
|
||||
rec = caldavGET(t, e, "/dav/projects/36/rev-parent-late.ics")
|
||||
assert.Contains(t, rec.Body.String(), "SUMMARY:Parent Late",
|
||||
"Parent should have its real title, not DUMMY-UID")
|
||||
assert.NotContains(t, rec.Body.String(), "DUMMY",
|
||||
"DUMMY placeholder should be replaced")
|
||||
|
||||
// Step 4: Verify child still has parent relation
|
||||
rec = caldavGET(t, e, "/dav/projects/36/rev-child-first.ics")
|
||||
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:rev-parent-late",
|
||||
"Child should still have parent relation after parent arrives")
|
||||
})
|
||||
|
||||
t.Run("Multiple children before parent", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Two children arrive before parent
|
||||
child1 := NewVTodo("rev-mc1", "Multi Child 1").
|
||||
RelatedToParent("rev-mparent").Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/rev-mc1.ics", child1)
|
||||
|
||||
child2 := NewVTodo("rev-mc2", "Multi Child 2").
|
||||
RelatedToParent("rev-mparent").Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/rev-mc2.ics", child2)
|
||||
|
||||
// Parent arrives
|
||||
parent := NewVTodo("rev-mparent", "Multi Parent").Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/rev-mparent.ics", parent)
|
||||
|
||||
// Verify parent shows both children
|
||||
rec := caldavGET(t, e, "/dav/projects/36/rev-mparent.ics")
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rev-mc1")
|
||||
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rev-mc2")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelationsCrossProject(t *testing.T) {
|
||||
t.Run("Parent in project 36, child in project 38", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
parent := NewVTodo("xp-parent", "Cross-Project Parent").Build()
|
||||
rec := caldavPUT(t, e, "/dav/projects/36/xp-parent.ics", parent)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
child := NewVTodo("xp-child", "Cross-Project Child").
|
||||
RelatedToParent("xp-parent").Build()
|
||||
rec = caldavPUT(t, e, "/dav/projects/38/xp-child.ics", child)
|
||||
require.Equal(t, 201, rec.Code)
|
||||
|
||||
// Verify parent in project 36 knows about child
|
||||
rec = caldavGET(t, e, "/dav/projects/36/xp-parent.ics")
|
||||
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:xp-child",
|
||||
"Parent should have cross-project child relation")
|
||||
|
||||
// Verify child in project 38 knows about parent
|
||||
rec = caldavGET(t, e, "/dav/projects/38/xp-child.ics")
|
||||
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:xp-parent",
|
||||
"Child should have cross-project parent relation")
|
||||
})
|
||||
|
||||
t.Run("Pre-existing cross-project relations from fixtures", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Task 45 (project 36) and task 46 (project 38) have cross-project relations in fixtures
|
||||
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task-another-list.ics")
|
||||
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task-another-list")
|
||||
|
||||
rec = caldavGET(t, e, "/dav/projects/38/uid-caldav-test-child-task-another-list.ics")
|
||||
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-another-list")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelationsDeletion(t *testing.T) {
|
||||
t.Run("Deleting child removes relation from parent", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Task 41 is parent of task 43 (from fixtures)
|
||||
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test-child-task.ics")
|
||||
assert.Equal(t, 204, rec.Code)
|
||||
|
||||
// Parent should no longer reference deleted child
|
||||
rec = caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics")
|
||||
assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task\r\n",
|
||||
"Parent should not reference deleted child")
|
||||
})
|
||||
|
||||
t.Run("Deleting parent removes relation from child", func(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Delete parent task 41
|
||||
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics")
|
||||
assert.Equal(t, 204, rec.Code)
|
||||
|
||||
// Child should no longer reference deleted parent
|
||||
rec = caldavGET(t, e, "/dav/projects/36/uid-caldav-test-child-task.ics")
|
||||
assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task",
|
||||
"Child should not reference deleted parent")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelationsResync(t *testing.T) {
|
||||
t.Run("Parent re-sync without RELATED-TO preserves child relations", func(t *testing.T) {
|
||||
// This is the DAVx5 behavior: parent is updated (e.g., title change)
|
||||
// and re-synced without any RELATED-TO. The child-declared relations
|
||||
// should survive.
|
||||
|
||||
e := setupTestEnv(t)
|
||||
|
||||
// Create parent
|
||||
parent := NewVTodo("resync-parent", "Original Parent").Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/resync-parent.ics", parent)
|
||||
|
||||
// Create child with parent relation
|
||||
child := NewVTodo("resync-child", "Child").
|
||||
RelatedToParent("resync-parent").Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/resync-child.ics", child)
|
||||
|
||||
// Re-sync parent with updated title but NO RELATED-TO
|
||||
parentUpdated := NewVTodo("resync-parent", "Updated Parent Title").Build()
|
||||
caldavPUT(t, e, "/dav/projects/36/resync-parent.ics", parentUpdated)
|
||||
|
||||
// Verify relations survived
|
||||
rec := caldavGET(t, e, "/dav/projects/36/resync-parent.ics")
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "Updated Parent Title", "Title should be updated")
|
||||
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:resync-child",
|
||||
"Child relation should survive parent re-sync without RELATED-TO")
|
||||
|
||||
rec = caldavGET(t, e, "/dav/projects/36/resync-child.ics")
|
||||
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:resync-parent",
|
||||
"Parent relation on child should survive parent re-sync")
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue