diff --git a/cypress/e2e/list/list-view-kanban.spec.ts b/cypress/e2e/list/list-view-kanban.spec.ts index 68268304d..6949626ac 100644 --- a/cypress/e2e/list/list-view-kanban.spec.ts +++ b/cypress/e2e/list/list-view-kanban.spec.ts @@ -193,4 +193,15 @@ describe('List View Kanban', () => { cy.get('.kanban .bucket') .should('not.contain', task.title) }) + + it('Shows a button to filter the kanban board', () => { + const data = TaskFactory.create(10, { + list_id: 1, + bucket_id: 1, + }) + cy.visit('/lists/1/kanban') + + cy.get('.list-kanban .filter-container .base-button') + .should('exist') + }) }) \ No newline at end of file diff --git a/cypress/e2e/task/overview.spec.ts b/cypress/e2e/task/overview.spec.ts index 147c70b61..a84eb36ef 100644 --- a/cypress/e2e/task/overview.spec.ts +++ b/cypress/e2e/task/overview.spec.ts @@ -128,4 +128,24 @@ describe('Home Page Task Overview', () => { .last() .should('contain.text', newTaskTitle) }) + + it('Should show the cta buttons for new list when there are no tasks', () => { + TaskFactory.truncate() + + cy.visit('/') + + cy.get('.home.app-content .content') + .should('contain.text', 'You can create a new list for your new tasks:') + .should('contain.text', 'Or import your lists and tasks from other services into Vikunja:') + }) + + it('Should not show the cta buttons for new list when there are tasks', () => { + seedTasks() + + cy.visit('/') + + cy.get('.home.app-content .content') + .should('not.contain.text', 'You can create a new list for your new tasks:') + .should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:') + }) }) diff --git a/cypress/e2e/task/task.spec.ts b/cypress/e2e/task/task.spec.ts index b5fa3c57f..2752e651b 100644 --- a/cypress/e2e/task/task.spec.ts +++ b/cypress/e2e/task/task.spec.ts @@ -12,15 +12,51 @@ import {LabelTaskFactory} from '../../factories/label_task' import {BucketFactory} from '../../factories/bucket' import '../../support/authenticateUser' +import {TaskAttachmentFactory} from '../../factories/task_attachments' + +function addLabelToTaskAndVerify(labelTitle: string) { + cy.get('.task-view .action-buttons .button') + .contains('Add Labels') + .click() + cy.get('.task-view .details.labels-list .multiselect input') + .type(labelTitle) + cy.get('.task-view .details.labels-list .multiselect .search-results') + .children() + .first() + .click() + + cy.get('.global-notification', { timeout: 4000 }) + .should('contain', 'Success') + cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag') + .should('exist') + .should('contain', labelTitle) +} + +function uploadAttachmentAndVerify(taskId: number) { + cy.intercept(`${Cypress.env('API_URL')}/tasks/${taskId}/attachments`).as('uploadAttachment') + cy.get('.task-view .action-buttons .button') + .contains('Add Attachments') + .click() + cy.get('input[type=file]', {timeout: 1000}) + .selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose + cy.wait('@uploadAttachment') + + cy.get('.attachments .attachments .files a.attachment') + .should('exist') +} describe('Task', () => { let namespaces let lists + let buckets beforeEach(() => { UserFactory.create(1) namespaces = NamespaceFactory.create(1) lists = ListFactory.create(1) + buckets = BucketFactory.create(1, { + list_id: lists[0].id, + }) TaskFactory.truncate() UserListFactory.truncate() }) @@ -80,6 +116,7 @@ describe('Task', () => { describe('Task Detail View', () => { beforeEach(() => { TaskCommentFactory.truncate() + LabelTaskFactory.truncate() }) it('Shows all task details', () => { @@ -344,21 +381,31 @@ describe('Task', () => { cy.visit(`/tasks/${tasks[0].id}`) - cy.get('.task-view .action-buttons .button') - .contains('Add Labels') + addLabelToTaskAndVerify(labels[0].title) + }) + + it('Can add a label to a task and it shows up on the kanban board afterwards', () => { + const tasks = TaskFactory.create(1, { + id: 1, + list_id: lists[0].id, + bucket_id: buckets[0].id, + }) + const labels = LabelFactory.create(1) + LabelTaskFactory.truncate() + + cy.visit(`/lists/${lists[0].id}/kanban`) + + cy.get('.bucket .task') + .contains(tasks[0].title) .click() - cy.get('.task-view .details.labels-list .multiselect input') - .type(labels[0].title) - cy.get('.task-view .details.labels-list .multiselect .search-results') - .children() - .first() + + addLabelToTaskAndVerify(labels[0].title) + + cy.get('.modal-content .close') .click() - - cy.get('.global-notification', { timeout: 4000 }) - .should('contain', 'Success') - cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag') - .should('exist') - .should('contain', labels[0].title) + + cy.get('.bucket .task') + .should('contain.text', labels[0].title) }) it('Can remove a label from a task', () => { @@ -417,5 +464,117 @@ describe('Task', () => { cy.get('.global-notification') .should('contain', 'Success') }) + + it('Can set a priority for a task', () => { + const tasks = TaskFactory.create(1, { + id: 1, + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .action-buttons .button') + .contains('Set Priority') + .click() + cy.get('.task-view .columns.details .column') + .contains('Priority') + .get('.select select') + .select('Urgent') + cy.get('.global-notification') + .should('contain', 'Success') + + cy.get('.task-view .columns.details .column') + .contains('Priority') + .get('.select select') + .should('have.value', '4') + }) + + it('Can set the progress for a task', () => { + const tasks = TaskFactory.create(1, { + id: 1, + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .action-buttons .button') + .contains('Set Progress') + .click() + cy.get('.task-view .columns.details .column') + .contains('Progress') + .get('.select select') + .select('50%') + cy.get('.global-notification') + .should('contain', 'Success') + + cy.wait(200) + + cy.get('.task-view .columns.details .column') + .contains('Progress') + .get('.select select') + .should('be.visible') + .should('have.value', '0.5') + }) + + it('Can add an attachment to a task', () => { + TaskAttachmentFactory.truncate() + const tasks = TaskFactory.create(1, { + id: 1, + }) + cy.visit(`/tasks/${tasks[0].id}`) + + uploadAttachmentAndVerify(tasks[0].id) + }) + + it('Can add an attachment to a task and see it appearing on kanban', () => { + TaskAttachmentFactory.truncate() + const tasks = TaskFactory.create(1, { + id: 1, + list_id: lists[0].id, + bucket_id: buckets[0].id, + }) + const labels = LabelFactory.create(1) + LabelTaskFactory.truncate() + + cy.visit(`/lists/${lists[0].id}/kanban`) + + cy.get('.bucket .task') + .contains(tasks[0].title) + .click() + + uploadAttachmentAndVerify(tasks[0].id) + + cy.get('.modal-content .close') + .click() + + cy.get('.bucket .task .footer .icon svg.fa-paperclip') + .should('exist') + }) + + it('Can check items off a checklist', () => { + const tasks = TaskFactory.create(1, { + id: 1, + description: ` +This is a checklist: + +* [ ] one item +* [ ] another item +* [ ] third item +* [ ] fourth item +* [x] and this one is already done +`, + }) + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .checklist-summary') + .should('contain.text', '1 of 5 tasks') + cy.get('.editor .content ul > li input[type=checkbox]') + .eq(2) + .click() + + cy.get('.editor .content ul > li input[type=checkbox]') + .eq(2) + .should('be.checked') + cy.get('.editor .content input[type=checkbox]') + .should('have.length', 5) + cy.get('.task-view .checklist-summary') + .should('contain.text', '2 of 5 tasks') + }) }) }) diff --git a/cypress/e2e/user/login.spec.ts b/cypress/e2e/user/login.spec.ts index 238ec77f4..80bf231e7 100644 --- a/cypress/e2e/user/login.spec.ts +++ b/cypress/e2e/user/login.spec.ts @@ -55,4 +55,9 @@ context('Login', () => { testAndAssertFailed(fixture) }) + + it('Should redirect to /login when no user is logged in', () => { + cy.visit('/') + cy.url().should('include', '/login') + }) }) diff --git a/cypress/factories/task_attachments.ts b/cypress/factories/task_attachments.ts new file mode 100644 index 000000000..2db80781c --- /dev/null +++ b/cypress/factories/task_attachments.ts @@ -0,0 +1,17 @@ +import {Factory} from '../support/factory' +import {formatISO} from 'date-fns' + +export class TaskAttachmentFactory extends Factory { + static table = 'task_attachments' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + task_id: 1, + file_id: 1, + created: formatISO(now), + } + } +} \ No newline at end of file diff --git a/renovate.json b/renovate.json index 0570abeb1..f26da76ce 100644 --- a/renovate.json +++ b/renovate.json @@ -6,7 +6,7 @@ ], "packageRules": [ { - "matchPackageNames": ["netlify-cli"], + "matchPackageNames": ["netlify-cli", "happy-dom"], "extends": ["schedule:weekly"] }, { diff --git a/src/components/home/TheNavigation.vue b/src/components/home/TheNavigation.vue index 5569a1cca..77d0b6e0e 100644 --- a/src/components/home/TheNavigation.vue +++ b/src/components/home/TheNavigation.vue @@ -44,8 +44,8 @@ variant="secondary" :shadow="false" > - - {{ userInfo.name !== '' ? userInfo.name : userInfo.username }} + + {{ authStore.userDisplayName }} @@ -80,7 +80,7 @@ {{ $t('about.title') }} {{ $t('user.auth.logout') }} @@ -117,8 +117,6 @@ const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Righ const menuActive = computed(() => baseStore.menuActive) const authStore = useAuthStore() -const userInfo = computed(() => authStore.info) -const userAvatar = computed(() => authStore.avatarUrl) const configStore = useConfigStore() const imprintUrl = computed(() => configStore.legal.imprintUrl) @@ -136,10 +134,6 @@ onMounted(async () => { listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`) }) -function logout() { - authStore.logout() -} - function openQuickActions() { baseStore.setQuickActionsActive(true) } diff --git a/src/components/input/editor.vue b/src/components/input/editor.vue index d106813cb..c2041dd8d 100644 --- a/src/components/input/editor.vue +++ b/src/components/input/editor.vue @@ -285,9 +285,9 @@ function handleCheckboxClick(e: Event) { console.debug('no index found') return } - console.debug(index, text.value.slice(index, 9)) - - const listPrefix = text.value.slice(index, 1) + const listPrefix = text.value.substring(index, index + 1) + + console.debug({index, listPrefix, checked, text: text.value}) text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `) bubble() diff --git a/src/components/list/list-settings-dropdown.vue b/src/components/list/list-settings-dropdown.vue index b56adcdcd..365485782 100644 --- a/src/components/list/list-settings-dropdown.vue +++ b/src/components/list/list-settings-dropdown.vue @@ -55,13 +55,13 @@ > {{ $t('menu.archive') }} - (null) watchEffect(() => { subscription.value = props.list.subscription ?? null @@ -100,4 +104,14 @@ watchEffect(() => { const configStore = useConfigStore() const backgroundsEnabled = computed(() => configStore.enabledBackgroundProviders?.length > 0) + +function setSubscriptionInStore(sub: ISubscription) { + subscription.value = sub + const updatedList = { + ...props.list, + subscription: sub, + } + listStore.setList(updatedList) + namespaceStore.setListInNamespaceById(updatedList) +} diff --git a/src/components/list/partials/filters.vue b/src/components/list/partials/filters.vue index 0c886b984..d9a0a46e2 100644 --- a/src/components/list/partials/filters.vue +++ b/src/components/list/partials/filters.vue @@ -210,6 +210,7 @@ import ListService from '@/services/list' import NamespaceService from '@/services/namespace' import EditLabels from '@/components/tasks/partials/editLabels.vue' +import {dateIsValid, formatISO} from '@/helpers/time/formatDate' import {objectToSnakeCase} from '@/helpers/case' import {getDefaultParams} from '@/composables/taskList' import {camelCase} from 'camel-case' @@ -391,7 +392,14 @@ export default defineComponent({ this.params.filter_value.push(dateTo) } - this.filters[camelCase(filterName)] = {dateFrom, dateTo} + this.filters[camelCase(filterName)] = { + // Passing the dates as string values avoids an endless loop between values changing + // in the datepicker (bubbling up to here) and changing here and bubbling down to the + // datepicker (because there's a new date instance every time this function gets called). + // See https://kolaente.dev/vikunja/frontend/issues/2384 + dateFrom: dateIsValid(dateFrom) ? formatISO(dateFrom) : dateFrom, + dateTo: dateIsValid(dateTo) ? formatISO(dateTo) : dateTo, + } this.change() return } @@ -511,12 +519,12 @@ export default defineComponent({ if (typeof this.filters[filterName] === 'undefined' || this.filters[filterName] === '') { return } - + // Don't load things if we already have something loaded. // This is not the most ideal solution because it prevents a re-population when filters are changed // from the outside. It is still fine because we're not changing them from the outside, other than // loading them initially. - if(this[kind].length > 0) { + if (this[kind].length > 0) { return } diff --git a/src/components/misc/create-edit.vue b/src/components/misc/create-edit.vue index 35441c320..1e07fe42e 100644 --- a/src/components/misc/create-edit.vue +++ b/src/components/misc/create-edit.vue @@ -10,7 +10,7 @@ :loading="loading" >
- +
- + @@ -316,7 +348,7 @@ function copyUrl(attachment: IAttachment) { height: auto; text-shadow: var(--shadow-md); animation: bounce 2s infinite; - + @media (prefers-reduced-motion: reduce) { animation: none; } @@ -338,7 +370,7 @@ function copyUrl(attachment: IAttachment) { .attachment-info-meta { display: flex; align-items: center; - + :deep(.user) { display: flex !important; align-items: center; @@ -348,7 +380,7 @@ function copyUrl(attachment: IAttachment) { @media screen and (max-width: $mobile) { flex-direction: column; align-items: flex-start; - + :deep(.user) { margin: .5rem 0; } @@ -394,5 +426,13 @@ function copyUrl(attachment: IAttachment) { } } +.is-task-cover { + background: var(--primary); + color: var(--white); + padding: .25rem .35rem; + border-radius: 4px; + font-size: .75rem; +} + @include modal-transition(); \ No newline at end of file diff --git a/src/components/tasks/partials/editAssignees.vue b/src/components/tasks/partials/editAssignees.vue index 06ca42d1d..d3c1e9c8b 100644 --- a/src/components/tasks/partials/editAssignees.vue +++ b/src/components/tasks/partials/editAssignees.vue @@ -1,34 +1,29 @@