Compare commits

..

42 Commits

Author SHA1 Message Date
603e8abdc9 chore(deps): update dependency netlify-cli to v10.9.0
All checks were successful
continuous-integration/drone/pr Build is passing
2022-07-13 21:04:42 +00:00
a4c3939fb6
fix: make sure saved filter data is correctly populated when editing a filter
All checks were successful
continuous-integration/drone/push Build is passing
Resolves #2114
2022-07-13 17:52:42 +02:00
ef0fe0b11d
fix(tests): correctly set task position in cypress test fixtures
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-13 17:00:38 +02:00
99cd68ad43
fix(kanban): make sure the task position is calculated correctly
Some checks failed
continuous-integration/drone/push Build is failing
The very first task in a bucket always has the position 0. Now, if we move another task in front of that, it too gets the position 0 assigned. That means the two first tasks now both have the position 0 and are not sorted correctly. This commit fixes that: When moving a task to the very first position it checks if the task now on the second position also has position 0 assigned to it. If that's the case, we'll now update that task's position as well to make sure it has another position than 0.
2022-07-13 16:51:56 +02:00
302728526a
chore(quick add magic): clarify the use of spaces for lists and labels
Some checks failed
continuous-integration/drone/push Build is failing
2022-07-13 16:31:30 +02:00
99a5afc817
fix: task sorting by position in list view
Some checks failed
continuous-integration/drone/push Build is failing
Resolves #2119
2022-07-13 16:24:50 +02:00
4a8b7a726a
fix: task sorting in table
All checks were successful
continuous-integration/drone/push Build is passing
Resolves #2118
2022-07-13 16:19:58 +02:00
579cff647d
feat: allow marking a task done from a filter
All checks were successful
continuous-integration/drone/push Build is passing
Resolves #2113
2022-07-12 11:59:39 +02:00
2a20c95ba5
fix(tests): remove old label task relations before adding a new one
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-12 09:12:04 +02:00
97e6147351
feat(tests): change cypress default viewport size 2022-07-12 09:09:26 +02:00
e8705c66dd
fix: add a task relation with enter when only one search result is available
Some checks failed
continuous-integration/drone/push Build is failing
Resolves #2107
2022-07-11 20:02:35 +02:00
6973d76e17
feat: select a value when there is one exact match in multiselect
Some checks failed
continuous-integration/drone/push Build is failing
Related to #2107
2022-07-11 19:55:03 +02:00
cc079336a8
fix: expose focus function for BaseButton
All checks were successful
continuous-integration/drone/push Build is passing
This fixes an issue with the usage of BaseButton in multiselect.
2022-07-11 17:06:18 +02:00
ab7bf7d8f9
fix: datepicker button color and spacing for overdue dates
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-11 16:41:08 +02:00
Dominik Pschenitschni
6e54929104 fix: pass modal bindings to teleport target (#2109)
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Co-authored-by: kolaente <k@knt.li>
Reviewed-on: #2109
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-07-11 13:27:57 +00:00
dff5d84ebb
fix: make sure weekday parsing in quick add magic ignores the casing
All checks were successful
continuous-integration/drone/push Build is passing
Resolves #2105
2022-07-11 12:35:08 +02:00
990639dd24
fix: setting a label on a task fails if the kanban view is open in the background
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2022-07-08 23:30:03 +02:00
a073cfac66
fix(tests): set correct user issuer for test users
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-08 17:22:33 +02:00
40b30079c1
fix(gantt): correctly show month and year in gantt chart on safari
Some checks failed
continuous-integration/drone/push Build is failing
Resolves https://github.com/go-vikunja/frontend/issues/59
2022-07-08 16:39:21 +02:00
f3835d7dfe fix(quick-add-magic): use ButtonLink
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-06 21:07:26 +00:00
9a26310ad6 fix(ListList): use ButtonLink 2022-07-06 21:07:26 +00:00
6ddede4863 feat(BaseButton): add target _blank for links by default 2022-07-06 21:07:26 +00:00
12544c52ca fix: add ButtonLink component
Add ButtonLink component to fix occasions where the BaseButton needs to be styled in a link color.
2022-07-06 21:07:26 +00:00
02f985d8a3 fix: button styling 2022-07-06 21:07:26 +00:00
3b9bc5b2f8 feat: use BaseButton where easily possible
This replaces links with BaseButton components. BaseButton will use `<button type="button">` inside for this case. This improves accessibility a lot. Also we might be able to remove the `.stop` modifiers in some places because AFAIK the button element stops propagation by default.
2022-07-06 21:07:26 +00:00
9e1ec72739 feat: use inline-block for BaseButton 2022-07-06 21:07:26 +00:00
Dominik Pschenitschni
2c2fc4c9ee [skip ci] Updated translations via Crowdin 2022-07-05 00:12:36 +00:00
c6d214b9eb fix: cypress plugins
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-04 21:50:48 +00:00
77466e3373 fix: cypress plugins import 2022-07-04 21:50:48 +00:00
8f82dd2783 fix: reenable some compilerOptions 2022-07-04 21:50:48 +00:00
58358481bc fix linting 2022-07-04 21:50:48 +00:00
321850ec20 chore: rename js files to ts 2022-07-04 21:50:48 +00:00
7fe9f17e43 feat: setup cypress 2022-07-04 21:50:48 +00:00
d064f0acc0 fix import type 2022-07-04 21:50:48 +00:00
c6aac15d24 feat: improve ts setup 2022-07-04 21:50:48 +00:00
513a51fb73 feat: move eslint config to external file to support comments 2022-07-04 21:50:48 +00:00
4070d64404 chore: remove unused import 2022-07-04 21:50:48 +00:00
Dominik Pschenitschni
4cd6857072 fix(password): watcher (#2097)
Some checks failed
continuous-integration/drone/push Build is failing
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: #2097
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-07-04 15:31:17 +00:00
580b012993
feat: add inputmode=generic to totp fields
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-04 16:55:27 +02:00
f9b892e32f fix(deps): update sentry-javascript monorepo to v7.5.0 (#2102)
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #2102
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-07-04 14:20:07 +00:00
6b4964c5a8 fix(deps): update dependency vue-router to v4.1.0 (#2101)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #2101
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-07-04 13:36:46 +00:00
6d36cec91e chore(deps): update caniuse-and-related (#2100)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #2100
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-07-04 08:54:27 +00:00
112 changed files with 1441 additions and 1014 deletions

51
.eslintrc.js Normal file
View File

@ -0,0 +1,51 @@
module.exports = {
'root': true,
'env': {
'browser': true,
'es2021': true,
'node': true,
'vue/setup-compiler-macros': true,
},
'extends': [
'eslint:recommended',
'plugin:vue/vue3-essential',
'@vue/typescript',
],
'rules': {
'vue/html-quotes': [
'error',
'double',
],
'quotes': [
'error',
'single',
],
'comma-dangle': [
'error',
'always-multiline',
],
'semi': [
'error',
'never',
],
'vue/script-setup-uses-vars': 'error',
// see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese)
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
'vue/multi-word-component-names': 0,
},
'parser': 'vue-eslint-parser',
'parserOptions': {
'parser': '@typescript-eslint/parser',
'ecmaVersion': 2022,
},
'ignorePatterns': [
'*.test.*',
'cypress/*',
],
'globals': {
'defineProps': 'readonly',
},
}

View File

@ -18,13 +18,11 @@
"javascriptreact", "javascriptreact",
"vue" "vue"
], ],
"vetur.validation.template": false,
// i18n ally // i18n ally
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"src/i18n/lang" "src/i18n/lang"
], ],
"i18n-ally.sortKeys": true, "i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": true, "i18n-ally.keepFulfilled": true,
"i18n-ally.keystyle": "nested", "i18n-ally.keystyle": "nested"
} }

View File

@ -11,12 +11,15 @@ export default defineConfig({
}, },
projectId: '181c7x', projectId: '181c7x',
e2e: { e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:4173', baseUrl: 'http://localhost:4173',
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
}, },
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
},
viewportWidth: 1600,
viewportHeight: 900,
}) })

View File

@ -61,7 +61,7 @@ describe('List View List', () => {
}) })
cy.visit(`/lists/${lists[1].id}/`) cy.visit(`/lists/${lists[1].id}/`)
cy.get('.list-title a.icon') cy.get('.list-title .icon')
.should('not.exist') .should('not.exist')
cy.get('input.input[placeholder="Add a new task..."') cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist') .should('not.exist')
@ -91,7 +91,9 @@ describe('List View List', () => {
cy.visit('/lists/1/list') cy.visit('/lists/1/list')
cy.get('.tasks-container .tasks') cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title) .should('contain', tasks[1].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link') cy.get('.card-content .pagination .pagination-link')
.contains('2') .contains('2')
@ -100,8 +102,8 @@ describe('List View List', () => {
cy.url() cy.url()
.should('contain', '?page=2') .should('contain', '?page=2')
cy.get('.tasks-container .tasks') cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title) .should('contain', tasks[99].title)
cy.get('.tasks-container .tasks') cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title) .should('not.contain', tasks[1].title)
}) })
}) })

View File

@ -52,9 +52,9 @@ describe('Lists', () => {
cy.get('.list-title h1') cy.get('.list-title h1')
.should('contain', 'First List') .should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger') cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-trigger')
.click() .click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content') cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit') .contains('Edit')
.click() .click()
cy.get('#title') cy.get('#title')
@ -68,7 +68,7 @@ describe('Lists', () => {
cy.get('.list-title h1') cy.get('.list-title h1')
.should('contain', newListName) .should('contain', newListName)
.should('not.contain', lists[0].title) .should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child') cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
.should('contain', newListName) .should('contain', newListName)
.should('not.contain', lists[0].title) .should('not.contain', lists[0].title)
cy.visit('/') cy.visit('/')
@ -80,9 +80,9 @@ describe('Lists', () => {
it('Should remove a list', () => { it('Should remove a list', () => {
cy.visit(`/lists/${lists[0].id}`) cy.visit(`/lists/${lists[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger') cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-trigger')
.click() .click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content') cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete') .contains('Delete')
.click() .click()
cy.url() cy.url()
@ -93,7 +93,7 @@ describe('Lists', () => {
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list') cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', lists[0].title) .should('not.contain', lists[0].title)
cy.location('pathname') cy.location('pathname')
.should('equal', '/') .should('equal', '/')
@ -112,7 +112,7 @@ describe('Lists', () => {
cy.get('.modal-content [data-cy=modalPrimary]') cy.get('.modal-content [data-cy=modalPrimary]')
.click() .click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list') cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', lists[0].title) .should('not.contain', lists[0].title)
cy.get('main.app-content') cy.get('main.app-content')
.should('contain.text', 'This list is archived. It is not possible to create new or edit tasks for it.') .should('contain.text', 'This list is archived. It is not possible to create new or edit tasks for it.')

View File

@ -165,7 +165,7 @@ describe('Task', () => {
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.content.description .editor a') cy.get('.task-view .details.content.description .editor button')
.click() .click()
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll') cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.type('{selectall}New Description') .type('{selectall}New Description')
@ -297,7 +297,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee') cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee')
.get('a.remove-assignee') .get('.remove-assignee')
.click() .click()
cy.get('.global-notification') cy.get('.global-notification')
@ -340,6 +340,7 @@ describe('Task', () => {
list_id: 1, list_id: 1,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
@ -402,7 +403,7 @@ describe('Task', () => {
.contains('Due Date') .contains('Due Date')
.get('.date-input .datepicker .show') .get('.date-input .datepicker .show')
.click() .click()
cy.get('.datepicker .datepicker-popup a') cy.get('.datepicker .datepicker-popup button')
.contains('Tomorrow') .contains('Tomorrow')
.click() .click()
cy.get('[data-cy="closeDatepicker"]') cy.get('[data-cy="closeDatepicker"]')

View File

@ -15,6 +15,7 @@ export class TaskFactory extends Factory {
list_id: 1, list_id: 1,
created_by_id: 1, created_by_id: 1,
index: '{increment}', index: '{increment}',
position: '{increment}',
created: formatISO(now), created: formatISO(now),
updated: formatISO(now) updated: formatISO(now)
} }

View File

@ -14,6 +14,7 @@ export class UserFactory extends Factory {
username: faker.lorem.word(10) + faker.datatype.uuid(), username: faker.lorem.word(10) + faker.datatype.uuid(),
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234 password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
status: 0, status: 0,
issuer: 'local',
created: formatISO(now), created: formatISO(now),
updated: formatISO(now) updated: formatISO(now)
} }

View File

@ -1,21 +0,0 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View File

@ -1,33 +0,0 @@
/**
* Recursively gets an element, returning only after it's determined to be attached to the DOM for good.
*
* Source: https://github.com/cypress-io/cypress/issues/7306#issuecomment-850621378
*/
Cypress.Commands.add('getSettled', (selector, opts = {}) => {
const retries = opts.retries || 3
const delay = opts.delay || 100
const isAttached = (resolve, count = 0) => {
const el = Cypress.$(selector)
// is element attached to the DOM?
count = Cypress.dom.isAttached(el) ? count + 1 : 0
// hit our base case, return the element
if (count >= retries) {
return resolve(el)
}
// retry after a bit of a delay
setTimeout(() => isAttached(resolve, count), delay)
}
// wrap, so we can chain cypress commands off the result
return cy.wrap(null).then(() => {
return new Cypress.Promise((resolve) => {
return isAttached(resolve, 0)
}).then((el) => {
return cy.wrap(el)
})
})
})

View File

@ -0,0 +1,71 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
/**
* Recursively gets an element, returning only after it's determined to be attached to the DOM for good.
*
* Source: https://github.com/cypress-io/cypress/issues/7306#issuecomment-850621378
*/
Cypress.Commands.add('getSettled', (selector, opts = {}) => {
const retries = opts.retries || 3
const delay = opts.delay || 100
const isAttached = (resolve, count = 0) => {
const el = Cypress.$(selector)
// is element attached to the DOM?
count = Cypress.dom.isAttached(el) ? count + 1 : 0
// hit our base case, return the element
if (count >= retries) {
return resolve(el)
}
// retry after a bit of a delay
setTimeout(() => isAttached(resolve, count), delay)
}
// wrap, so we can chain cypress commands off the result
return cy.wrap(null).then(() => {
return new Cypress.Promise((resolve) => {
return isAttached(resolve, 0)
}).then((el) => {
return cy.wrap(el)
})
})
})

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

View File

@ -0,0 +1,29 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { mount } from 'cypress/vue'
// Ensure global styles are loaded
import '../../src/styles/global.scss';
Cypress.Commands.add('mount', mount)
// Example use:
// cy.mount(MyComponent)

10
cypress/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["./integration/**/*", "./support/**/*"],
"compilerOptions": {
"isolatedModules": false,
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
}
}

3
env.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />
/// <reference types="cypress" />

View File

@ -9,19 +9,19 @@
"build": "vite build && workbox copyLibraries dist/", "build": "vite build && workbox copyLibraries dist/",
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/", "build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
"build:dev": "vite build -m development --outDir dist-dev/", "build:dev": "vite build -m development --outDir dist-dev/",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts", "lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"test:unit": "vitest run", "test:unit": "vitest",
"test:unit-watch": "vitest watch", "test:unit-watch": "vitest watch",
"test:frontend": "cypress run", "test:frontend": "cypress run",
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"browserslist:update": "npx browserslist@latest --update-db" "browserslist:update": "npx browserslist@latest --update-db"
}, },
"dependencies": { "dependencies": {
"@github/hotkey": "2.0.0", "@github/hotkey": "2.0.0",
"@kyvg/vue3-notification": "2.3.4", "@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "7.4.1", "@sentry/tracing": "7.5.0",
"@sentry/vue": "7.4.1", "@sentry/vue": "7.5.0",
"@types/is-touch-device": "1.0.0", "@types/is-touch-device": "1.0.0",
"@types/sortablejs": "1.13.0", "@types/sortablejs": "1.13.0",
"@vueuse/core": "8.7.5", "@vueuse/core": "8.7.5",
@ -49,13 +49,15 @@
"vue-drag-resize": "2.0.3", "vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.6", "vue-flatpickr-component": "9.0.6",
"vue-i18n": "9.2.0-beta.36", "vue-i18n": "9.2.0-beta.36",
"vue-router": "4.0.16", "vue-router": "4.1.0",
"vuex": "4.0.2", "vuex": "4.0.2",
"workbox-precaching": "6.5.3", "workbox-precaching": "6.5.3",
"zhyswan-vuedraggable": "4.1.3" "zhyswan-vuedraggable": "4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@4tw/cypress-drag-drop": "2.2.1", "@4tw/cypress-drag-drop": "2.2.1",
"@cypress/vite-dev-server": "2.2.2",
"@cypress/vue": "3.1.1",
"@faker-js/faker": "7.3.0", "@faker-js/faker": "7.3.0",
"@fortawesome/fontawesome-svg-core": "6.1.1", "@fortawesome/fontawesome-svg-core": "6.1.1",
"@fortawesome/free-regular-svg-icons": "6.1.1", "@fortawesome/free-regular-svg-icons": "6.1.1",
@ -67,17 +69,19 @@
"@vitejs/plugin-legacy": "1.8.2", "@vitejs/plugin-legacy": "1.8.2",
"@vitejs/plugin-vue": "2.3.3", "@vitejs/plugin-vue": "2.3.3",
"@vue/eslint-config-typescript": "11.0.0", "@vue/eslint-config-typescript": "11.0.0",
"@vue/test-utils": "2.0.0-rc.18",
"@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.7", "autoprefixer": "10.4.7",
"axios": "0.27.2", "axios": "0.27.2",
"browserslist": "4.20.4", "browserslist": "4.21.1",
"caniuse-lite": "1.0.30001357", "caniuse-lite": "1.0.30001363",
"cypress": "10.3.0", "cypress": "10.3.0",
"esbuild": "0.14.48", "esbuild": "0.14.48",
"eslint": "8.19.0", "eslint": "8.19.0",
"eslint-plugin-vue": "9.1.1", "eslint-plugin-vue": "9.1.1",
"express": "4.18.1", "express": "4.18.1",
"happy-dom": "6.0.0", "happy-dom": "6.0.0",
"netlify-cli": "10.7.1", "netlify-cli": "10.9.0",
"postcss": "8.4.14", "postcss": "8.4.14",
"postcss-preset-env": "7.7.2", "postcss-preset-env": "7.7.2",
"rollup": "2.75.7", "rollup": "2.75.7",
@ -92,52 +96,6 @@
"wait-on": "6.0.1", "wait-on": "6.0.1",
"workbox-cli": "6.5.3" "workbox-cli": "6.5.3"
}, },
"eslintConfig": {
"root": true,
"env": {
"browser": true,
"es2021": true,
"node": true,
"vue/setup-compiler-macros": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-essential",
"@vue/typescript"
],
"rules": {
"vue/html-quotes": [
"error",
"double"
],
"quotes": [
"error",
"single"
],
"comma-dangle": [
"error",
"always-multiline"
],
"semi": [
"error",
"never"
],
"vue/script-setup-uses-vars": "error",
"vue/multi-word-component-names": 0
},
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": 2022
},
"ignorePatterns": [
"*.test.*",
"cypress/*"
],
"globals": {
"defineProps": "readonly"
}
},
"postcss": { "postcss": {
"plugins": { "plugins": {
"autoprefixer": {} "autoprefixer": {}

View File

@ -5,6 +5,7 @@
:class="{ 'base-button--type-button': isButton }" :class="{ 'base-button--type-button': isButton }"
v-bind="elementBindings" v-bind="elementBindings"
:disabled="disabled || undefined" :disabled="disabled || undefined"
ref="button"
> >
<slot /> <slot />
</component> </component>
@ -53,7 +54,8 @@ const props = defineProps({
const componentNodeName = ref<Node['nodeName']>('button') const componentNodeName = ref<Node['nodeName']>('button')
interface ElementBindings { interface ElementBindings {
type?: string; type?: string;
rel?: string, rel?: string;
target?: string;
} }
const elementBindings = ref({}) const elementBindings = ref({})
@ -74,7 +76,10 @@ watchEffect(() => {
// we also set a predefined value for the attribute rel, but make it possible to overwrite this by the user. // we also set a predefined value for the attribute rel, but make it possible to overwrite this by the user.
if ('href' in attrs) { if ('href' in attrs) {
nodeName = 'a' nodeName = 'a'
bindings = {rel: 'noreferrer noopener nofollow'} bindings = {
rel: 'noreferrer noopener nofollow',
target: '_blank',
}
} }
componentNodeName.value = nodeName componentNodeName.value = nodeName
@ -85,6 +90,15 @@ watchEffect(() => {
}) })
const isButton = computed(() => componentNodeName.value === 'button') const isButton = computed(() => componentNodeName.value === 'button')
const button = ref()
function focus() {
button.value.focus()
}
defineExpose({
focus,
})
</script> </script>
<style lang="scss"> <style lang="scss">
@ -103,7 +117,7 @@ const isButton = computed(() => componentNodeName.value === 'button')
:where(.base-button) { :where(.base-button) {
cursor: pointer; cursor: pointer;
display: block; display: inline-block;
color: inherit; color: inherit;
font: inherit; font: inherit;
user-select: none; user-select: none;

View File

@ -85,7 +85,12 @@ import DatemathHelp from '@/components/date/datemathHelp.vue'
const store = useStore() const store = useStore()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const emit = defineEmits(['dateChanged']) const emit = defineEmits(['dateChanged', 'update:modelValue'])
const props = defineProps({
modelValue: {
required: false,
},
})
// FIXME: This seems to always contain the default value - that breaks the picker // FIXME: This seems to always contain the default value - that breaks the picker
const weekStart = computed<number>(() => store.state.auth.settings.weekStart ?? 0) const weekStart = computed<number>(() => store.state.auth.settings.weekStart ?? 0)
@ -108,11 +113,22 @@ const flatpickrRange = ref('')
const from = ref('') const from = ref('')
const to = ref('') const to = ref('')
watch(
() => props.modelValue,
newValue => {
from.value = newValue.dateFrom
to.value = newValue.dateTo
flatpickrRange.value = `${from.value} to ${to.value}`
},
)
function emitChanged() { function emitChanged() {
emit('dateChanged', { const args = {
dateFrom: from.value === '' ? null : from.value, dateFrom: from.value === '' ? null : from.value,
dateTo: to.value === '' ? null : to.value, dateTo: to.value === '' ? null : to.value,
}) }
emit('dateChanged', args)
emit('update:modelValue', args)
} }
watch( watch(

View File

@ -22,14 +22,14 @@
<div class="navbar-end"> <div class="navbar-end">
<update/> <update/>
<a <BaseButton
@click="openQuickActions" @click="openQuickActions"
class="trigger-button pr-0" class="trigger-button pr-0"
v-shortcut="'Control+k'" v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')" :title="$t('keyboardShortcuts.quickSearch')"
> >
<icon icon="search"/> <icon icon="search"/>
</a> </BaseButton>
<notifications/> <notifications/>
<div class="user"> <div class="user">
<dropdown class="is-right" ref="usernameDropdown"> <dropdown class="is-right" ref="usernameDropdown">

View File

@ -49,13 +49,13 @@
</modal> </modal>
</transition> </transition>
<a <BaseButton
class="keyboard-shortcuts-button d-print-none" class="keyboard-shortcuts-button d-print-none"
@click="showKeyboardShortcuts()" @click="showKeyboardShortcuts()"
v-shortcut="'?'" v-shortcut="'?'"
> >
<icon icon="keyboard"/> <icon icon="keyboard"/>
</a> </BaseButton>
</main> </main>
</div> </div>
</div> </div>

View File

@ -51,7 +51,7 @@
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}"> <nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id"> <template v-for="(n, nk) in namespaces" :key="n.id">
<div class="namespace-title" :class="{'has-menu': n.id > 0}"> <div class="namespace-title" :class="{'has-menu': n.id > 0}">
<span <BaseButton
@click="toggleLists(n.id)" @click="toggleLists(n.id)"
class="menu-label" class="menu-label"
v-tooltip="namespaceTitles[nk]" v-tooltip="namespaceTitles[nk]"
@ -61,32 +61,25 @@
:style="{ backgroundColor: n.hexColor }" :style="{ backgroundColor: n.hexColor }"
class="color-bubble" class="color-bubble"
/> />
<span class="name"> <span class="name">{{ namespaceTitles[nk] }}</span>
{{ namespaceTitles[nk] }} <div
</span>
<a
class="icon is-small toggle-lists-icon pl-2" class="icon is-small toggle-lists-icon pl-2"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}" :class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
@click="toggleLists(n.id)"
> >
<icon icon="chevron-down"/> <icon icon="chevron-down"/>
</a> </div>
<span class="count" :class="{'ml-2 mr-0': n.id > 0}"> <span class="count" :class="{'ml-2 mr-0': n.id > 0}">
({{ namespaceListsCount[nk] }}) ({{ namespaceListsCount[nk] }})
</span> </span>
</span> </BaseButton>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/> <namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div> </div>
<div
v-if="listsVisible[n.id] ?? true"
:key="n.id + 'child'"
class="more-container"
>
<!-- <!--
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
triggered by the change needs to have access to the current namespace triggered by the change needs to have access to the current namespace
--> -->
<draggable <draggable
v-if="listsVisible[n.id] ?? true"
v-bind="dragOptions" v-bind="dragOptions"
:modelValue="activeLists[nk]" :modelValue="activeLists[nk]"
@update:modelValue="(lists) => updateActiveLists(n, lists)" @update:modelValue="(lists) => updateActiveLists(n, lists)"
@ -111,19 +104,13 @@
> >
<template #item="{element: l}"> <template #item="{element: l}">
<li <li
class="loader-container is-loading-small" class="list-menu loader-container is-loading-small"
:class="{'is-loading': listUpdating[l.id]}" :class="{'is-loading': listUpdating[l.id]}"
> >
<router-link <BaseButton
:to="{ name: 'list.index', params: { listId: l.id} }" :to="{ name: 'list.index', params: { listId: l.id} }"
v-slot="{ href, navigate, isActive }"
custom
>
<a
@click="navigate"
:href="href"
class="list-menu-link" class="list-menu-link"
:class="{'router-link-exact-active': isActive || currentList?.id === l.id}" :class="{'router-link-exact-active': currentList.id === l.id}"
> >
<span class="icon handle"> <span class="icon handle">
<icon icon="grip-lines"/> <icon icon="grip-lines"/>
@ -133,23 +120,20 @@
class="color-bubble" class="color-bubble"
v-if="l.hexColor !== ''"> v-if="l.hexColor !== ''">
</span> </span>
<span class="list-menu-title"> <span class="list-menu-title">{{ getListTitle(l) }}</span>
{{ getListTitle(l) }} </BaseButton>
</span> <BaseButton
<span class="favorite"
:class="{'is-favorite': l.isFavorite}" :class="{'is-favorite': l.isFavorite}"
@click.prevent.stop="toggleFavoriteList(l)" @click="toggleFavoriteList(l)"
class="favorite"> >
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/> <icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</span> </BaseButton>
</a>
</router-link>
<list-settings-dropdown :list="l" v-if="l.id > 0"/> <list-settings-dropdown :list="l" v-if="l.id > 0"/>
<span class="list-setting-spacer" v-else></span> <span class="list-setting-spacer" v-else></span>
</li> </li>
</template> </template>
</draggable> </draggable>
</div>
</template> </template>
</nav> </nav>
<PoweredByLink/> <PoweredByLink/>
@ -162,6 +146,7 @@ import {useStore} from 'vuex'
import draggable from 'zhyswan-vuedraggable' import draggable from 'zhyswan-vuedraggable'
import {SortableEvent} from 'sortablejs' import {SortableEvent} from 'sortablejs'
import BaseButton from '@/components/base/BaseButton.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue' import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue' import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
import PoweredByLink from '@/components/home/PoweredByLink.vue' import PoweredByLink from '@/components/home/PoweredByLink.vue'
@ -334,7 +319,7 @@ $vikunja-nav-selected-width: 0.4rem;
} }
.menu-label, .menu-label,
.menu-list span.list-menu-link, .menu-list .list-menu-link,
.menu-list a { .menu-list a {
display: flex; display: flex;
align-items: center; align-items: center;
@ -352,30 +337,23 @@ $vikunja-nav-selected-width: 0.4rem;
flex: 0 0 12px; flex: 0 0 12px;
} }
}
.favorite { .favorite {
margin-left: .25rem; margin-left: .25rem;
transition: opacity $transition, color $transition; transition: opacity $transition, color $transition;
opacity: 0; opacity: 0;
&:hover { &:hover,
color: var(--warning);
}
&.is-favorite { &.is-favorite {
opacity: 1;
color: var(--warning); color: var(--warning);
} }
} }
&:hover .favorite { .favorite.is-favorite,
.list-menu:hover .favorite {
opacity: 1; opacity: 1;
} }
&:hover {
background: transparent;
}
}
.menu-label { .menu-label {
.color-bubble { .color-bubble {
width: 14px; width: 14px;
@ -392,6 +370,8 @@ $vikunja-nav-selected-width: 0.4rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
color: $vikunja-nav-color;
padding: 0 .25rem;
.menu-label { .menu-label {
margin-bottom: 0; margin-bottom: 0;
@ -410,11 +390,6 @@ $vikunja-nav-selected-width: 0.4rem;
} }
} }
a:not(.dropdown-item) {
color: $vikunja-nav-color;
padding: 0 .25rem;
}
:deep(.dropdown-trigger) { :deep(.dropdown-trigger) {
padding: .5rem; padding: .5rem;
cursor: pointer; cursor: pointer;
@ -444,7 +419,7 @@ $vikunja-nav-selected-width: 0.4rem;
.menu-label, .menu-label,
.nsettings, .nsettings,
.menu-list span.list-menu-link, .menu-list .list-menu-link,
.menu-list a { .menu-list a {
color: $vikunja-nav-color; color: $vikunja-nav-color;
} }
@ -483,7 +458,11 @@ $vikunja-nav-selected-width: 0.4rem;
} }
} }
span.list-menu-link, li > a { a:hover {
background: transparent;
}
.list-menu-link, li > a {
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem); padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
transition: all 0.2s ease; transition: all 0.2s ease;
@ -556,7 +535,7 @@ $vikunja-nav-selected-width: 0.4rem;
font-family: $vikunja-font; font-family: $vikunja-font;
} }
span.list-menu-link, li > a { .list-menu-link, li > a {
padding-left: 2rem; padding-left: 2rem;
display: inline-block; display: inline-block;

View File

@ -1,95 +1,73 @@
<template> <template>
<div class="datepicker" :class="{'disabled': disabled}"> <div class="datepicker">
<a @click.stop="toggleDatePopup" class="show"> <BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
<template v-if="date === null"> {{ date === null ? chooseDateLabel : formatDateShort(date) }}
{{ chooseDateLabel }} </BaseButton>
</template>
<template v-else>
{{ formatDateShort(date) }}
</template>
</a>
<transition name="fade"> <transition name="fade">
<div v-if="show" class="datepicker-popup" ref="datepickerPopup"> <div v-if="show" class="datepicker-popup" ref="datepickerPopup">
<a @click.stop="() => setDate('today')" v-if="(new Date()).getHours() < 21"> <BaseButton
<span class="icon"> v-if="(new Date()).getHours() < 21"
<icon :icon="['far', 'calendar-alt']"/> class="datepicker__quick-select-date"
</span> @click.stop="setDate('today')"
>
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
<span class="text"> <span class="text">
<span> <span>{{ $t('input.datepicker.today') }}</span>
{{ $t('input.datepicker.today') }} <span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('today') }}
</span>
</span>
</a>
<a @click.stop="() => setDate('tomorrow')">
<span class="icon">
<icon :icon="['far', 'sun']"/>
</span> </span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('tomorrow')"
>
<span class="icon"><icon :icon="['far', 'sun']"/></span>
<span class="text"> <span class="text">
<span> <span>{{ $t('input.datepicker.tomorrow') }}</span>
{{ $t('input.datepicker.tomorrow') }} <span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('tomorrow') }}
</span>
</span>
</a>
<a @click.stop="() => setDate('nextMonday')">
<span class="icon">
<icon icon="coffee"/>
</span> </span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextMonday')"
>
<span class="icon"><icon icon="coffee"/></span>
<span class="text"> <span class="text">
<span> <span>{{ $t('input.datepicker.nextMonday') }}</span>
{{ $t('input.datepicker.nextMonday') }} <span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('nextMonday') }}
</span>
</span>
</a>
<a @click.stop="() => setDate('thisWeekend')">
<span class="icon">
<icon icon="cocktail"/>
</span> </span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('thisWeekend')"
>
<span class="icon"><icon icon="cocktail"/></span>
<span class="text"> <span class="text">
<span> <span>{{ $t('input.datepicker.thisWeekend') }}</span>
{{ $t('input.datepicker.thisWeekend') }} <span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('thisWeekend') }}
</span>
</span>
</a>
<a @click.stop="() => setDate('laterThisWeek')">
<span class="icon">
<icon icon="chess-knight"/>
</span> </span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('laterThisWeek')"
>
<span class="icon"><icon icon="chess-knight"/></span>
<span class="text"> <span class="text">
<span> <span>{{ $t('input.datepicker.laterThisWeek') }}</span>
{{ $t('input.datepicker.laterThisWeek') }} <span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('laterThisWeek') }}
</span>
</span>
</a>
<a @click.stop="() => setDate('nextWeek')">
<span class="icon">
<icon icon="forward"/>
</span> </span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextWeek')"
>
<span class="icon"><icon icon="forward"/></span>
<span class="text"> <span class="text">
<span> <span>{{ $t('input.datepicker.nextWeek') }}</span>
{{ $t('input.datepicker.nextWeek') }} <span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
</span> </span>
<span class="weekday"> </BaseButton>
{{ getWeekdayFromStringInterval('nextWeek') }}
</span>
</span>
</a>
<flat-pickr <flat-pickr
:config="flatPickerConfig" :config="flatPickerConfig"
@ -98,7 +76,7 @@
/> />
<x-button <x-button
class="is-fullwidth" class="datepicker__close-button is-fullwidth"
:shadow="false" :shadow="false"
@click="close" @click="close"
v-cy="'closeDatepicker'" v-cy="'closeDatepicker'"
@ -117,6 +95,8 @@ import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css' import 'flatpickr/dist/flatpickr.css'
import {i18n} from '@/i18n' import {i18n} from '@/i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import {format} from 'date-fns' import {format} from 'date-fns'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval' import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours' import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
@ -134,6 +114,7 @@ export default defineComponent({
}, },
components: { components: {
flatPickr, flatPickr,
BaseButton,
}, },
props: { props: {
modelValue: { modelValue: {
@ -263,9 +244,6 @@ export default defineComponent({
input.input { input.input {
display: none; display: none;
} }
&.disabled a {
cursor: default;
} }
.datepicker-popup { .datepicker-popup {
@ -279,8 +257,9 @@ export default defineComponent({
@media screen and (max-width: ($tablet)) { @media screen and (max-width: ($tablet)) {
width: calc(100vw - 5rem); width: calc(100vw - 5rem);
} }
}
a:not(.button) { .datepicker__quick-select-date {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 .5rem; padding: 0 .5rem;
@ -316,7 +295,7 @@ export default defineComponent({
} }
} }
a.button { .datepicker__close-button {
margin: 1rem; margin: 1rem;
width: calc(100% - 2rem); width: calc(100% - 2rem);
} }
@ -325,6 +304,4 @@ export default defineComponent({
margin: 0 auto 8px; margin: 0 auto 8px;
box-shadow: none; box-shadow: none;
} }
}
}
</style> </style>

View File

@ -16,23 +16,23 @@
<p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText"> <p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText">
{{ emptyText }} {{ emptyText }}
<template v-if="isEditEnabled"> <template v-if="isEditEnabled">
<a @click="toggleEdit" class="d-print-none">{{ $t('input.editor.edit') }}</a>. <ButtonLink @click="toggleEdit" class="d-print-none">{{ $t('input.editor.edit') }}</ButtonLink>.
</template> </template>
</p> </p>
<ul class="actions d-print-none" v-if="bottomActions.length > 0"> <ul class="actions d-print-none" v-if="bottomActions.length > 0">
<li v-if="isEditEnabled && !showPreviewText && showSave"> <li v-if="isEditEnabled && !showPreviewText && showSave">
<a v-if="showEditButton" @click="toggleEdit">{{ $t('input.editor.edit') }}</a> <BaseButton v-if="showEditButton" @click="toggleEdit">{{ $t('input.editor.edit') }}</BaseButton>
<a v-else-if="isEditActive" @click="toggleEdit" class="done-edit">{{ $t('misc.save') }}</a> <BaseButton v-else-if="isEditActive" @click="toggleEdit" class="done-edit">{{ $t('misc.save') }}</BaseButton>
</li> </li>
<li v-for="(action, k) in bottomActions" :key="k"> <li v-for="(action, k) in bottomActions" :key="k">
<a @click="action.action">{{ action.title }}</a> <BaseButton @click="action.action">{{ action.title }}</BaseButton>
</li> </li>
</ul> </ul>
<template v-else-if="isEditEnabled && showSave"> <template v-else-if="isEditEnabled && showSave">
<ul v-if="showEditButton" class="actions d-print-none"> <ul v-if="showEditButton" class="actions d-print-none">
<li> <li>
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a> <BaseButton @click="toggleEdit">{{ $t('input.editor.edit') }}</BaseButton>
</li> </li>
</ul> </ul>
<x-button <x-button
@ -62,10 +62,15 @@ import AttachmentService from '../../services/attachment'
import {findCheckboxesInText} from '../../helpers/checklistFromText' import {findCheckboxesInText} from '../../helpers/checklistFromText'
import {createRandomID} from '@/helpers/randomId' import {createRandomID} from '@/helpers/randomId'
import BaseButton from '@/components/base/BaseButton.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
export default defineComponent({ export default defineComponent({
name: 'editor', name: 'editor',
components: { components: {
VueEasymde, VueEasymde,
BaseButton,
ButtonLink,
}, },
props: { props: {
modelValue: { modelValue: {

View File

@ -15,7 +15,7 @@
<slot name="tag" :item="item"> <slot name="tag" :item="item">
<span :key="`item${key}`" class="tag ml-2 mt-2"> <span :key="`item${key}`" class="tag ml-2 mt-2">
{{ label !== '' ? item[label] : item }} {{ label !== '' ? item[label] : item }}
<a @click="() => remove(item)" class="delete is-small"></a> <BaseButton @click="() => remove(item)" class="delete is-small"></BaseButton>
</span> </span>
</slot> </slot>
</template> </template>
@ -37,7 +37,7 @@
<transition name="fade"> <transition name="fade">
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible"> <div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<button <BaseButton
class="is-fullwidth" class="is-fullwidth"
v-for="(data, key) in filteredSearchResults" v-for="(data, key) in filteredSearchResults"
:key="key" :key="key"
@ -54,9 +54,9 @@
<span class="hint-text"> <span class="hint-text">
{{ selectPlaceholder }} {{ selectPlaceholder }}
</span> </span>
</button> </BaseButton>
<button <BaseButton
v-if="creatableAvailable" v-if="creatableAvailable"
class="is-fullwidth" class="is-fullwidth"
:ref="`result-${filteredSearchResults.length}`" :ref="`result-${filteredSearchResults.length}`"
@ -75,7 +75,7 @@
<span class="hint-text"> <span class="hint-text">
{{ createPlaceholder }} {{ createPlaceholder }}
</span> </span>
</button> </BaseButton>
</div> </div>
</transition> </transition>
@ -87,8 +87,22 @@ import {defineComponent} from 'vue'
import {i18n} from '@/i18n' import {i18n} from '@/i18n'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside' import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import BaseButton from '@/components/base/BaseButton.vue'
function elementInResults(elem: string | any, label: string, query: string): boolean {
// Don't make create available if we have an exact match in our search results.
if (label !== '') {
return elem[label] === query
}
return elem === query
}
export default defineComponent({ export default defineComponent({
name: 'multiselect', name: 'multiselect',
components: {
BaseButton,
},
data() { data() {
return { return {
query: '', query: '',
@ -217,14 +231,12 @@ export default defineComponent({
) )
}, },
creatableAvailable() { creatableAvailable() {
return this.creatable && this.query !== '' && !this.filteredSearchResults.some(elem => { const hasResult = this.filteredSearchResults.some(elem => elementInResults(elem, this.label, this.query))
// Don't make create available if we have an exact match in our search results. const hasQueryAlreadyAdded = Array.isArray(this.internalValue) && this.internalValue.some(elem => elementInResults(elem, this.label, this.query))
if (this.label !== '') {
return elem[this.label] === this.query
}
return elem === this.query return this.creatable
}) && this.query !== ''
&& !(hasResult || hasQueryAlreadyAdded)
}, },
filteredSearchResults() { filteredSearchResults() {
if (this.multiple && this.internalValue !== null && Array.isArray(this.internalValue)) { if (this.multiple && this.internalValue !== null && Array.isArray(this.internalValue)) {
@ -347,6 +359,12 @@ export default defineComponent({
} }
if (!this.creatableAvailable) { if (!this.creatableAvailable) {
// Check if there's an exact match for our search term
const exactMatch = this.filteredSearchResults.find(elem => elementInResults(elem, this.label, this.query))
if(exactMatch) {
this.select(exactMatch)
}
return return
} }

View File

@ -45,7 +45,7 @@ const password = ref('')
const isValid = ref(!props.validateInitially) const isValid = ref(!props.validateInitially)
watch( watch(
props.validateInitially, () => props.validateInitially,
() => props.validateInitially && validate(), () => props.validateInitially && validate(),
{immediate: true}, {immediate: true},
) )

View File

@ -67,7 +67,9 @@
<div class="field"> <div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label> <label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control"> <div class="control">
<datepicker-with-range @dateChanged="values => setDateFilter('due_date', values)"> <datepicker-with-range
@dateChanged="values => setDateFilter('due_date', values)"
v-model="filters.dueDate">
<template #trigger="{toggle, buttonText}"> <template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2"> <x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }} {{ buttonText }}
@ -79,7 +81,9 @@
<div class="field"> <div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label> <label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control"> <div class="control">
<datepicker-with-range @dateChanged="values => setDateFilter('start_date', values)"> <datepicker-with-range
@dateChanged="values => setDateFilter('start_date', values)"
v-model="filters.startDate">
<template #trigger="{toggle, buttonText}"> <template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2"> <x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }} {{ buttonText }}
@ -91,7 +95,9 @@
<div class="field"> <div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label> <label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control"> <div class="control">
<datepicker-with-range @dateChanged="values => setDateFilter('end_date', values)"> <datepicker-with-range
@dateChanged="values => setDateFilter('end_date', values)"
v-model="filters.endDate">
<template #trigger="{toggle, buttonText}"> <template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2"> <x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }} {{ buttonText }}
@ -103,7 +109,9 @@
<div class="field"> <div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label> <label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control"> <div class="control">
<datepicker-with-range @dateChanged="values => setDateFilter('reminders', values)"> <datepicker-with-range
@dateChanged="values => setDateFilter('reminders', values)"
v-model="filters.reminders">
<template #trigger="{toggle, buttonText}"> <template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2"> <x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }} {{ buttonText }}
@ -137,7 +145,8 @@
</div> </div>
</div> </div>
<template v-if="$route.name === 'filters.create' || $route.name === 'list.edit'"> <template
v-if="$route.name === 'filters.create' || $route.name === 'list.edit' || $route.name === 'filter.settings.edit'">
<div class="field"> <div class="field">
<label class="label">{{ $t('list.lists') }}</label> <label class="label">{{ $t('list.lists') }}</label>
<div class="control"> <div class="control">
@ -193,6 +202,7 @@ import EditLabels from '@/components/tasks/partials/editLabels.vue'
import {objectToSnakeCase} from '@/helpers/case' import {objectToSnakeCase} from '@/helpers/case'
import {getDefaultParams} from '@/composables/taskList' import {getDefaultParams} from '@/composables/taskList'
import {camelCase} from 'camel-case'
// FIXME: merge with DEFAULT_PARAMS in taskList.js // FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = { const DEFAULT_PARAMS = {
@ -368,6 +378,8 @@ export default defineComponent({
this.params.filter_comparator.push('less_equals') this.params.filter_comparator.push('less_equals')
this.params.filter_value.push(dateTo) this.params.filter_value.push(dateTo)
} }
this.filters[camelCase(filterName)] = {dateFrom, dateTo}
this.change() this.change()
return return
} }
@ -397,9 +409,16 @@ export default defineComponent({
} }
if (foundDateStart !== false && foundDateEnd !== false) { if (foundDateStart !== false && foundDateEnd !== false) {
const start = new Date(this.params.filter_value[foundDateStart]) const startDate = new Date(this.params.filter_value[foundDateStart])
const end = new Date(this.params.filter_value[foundDateEnd]) const endDate = new Date(this.params.filter_value[foundDateEnd])
this.filters[variableName] = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()} to ${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}` this.filters[variableName] = {
dateFrom: !isNaN(startDate)
? `${startDate.getFullYear()}-${startDate.getMonth() + 1}-${startDate.getDate()}`
: this.params.filter_value[foundDateStart],
dateTo: !isNaN(endDate)
? `${endDate.getFullYear()}-${endDate.getMonth() + 1}-${endDate.getDate()}`
: this.params.filter_value[foundDateEnd],
}
} }
}, },
setSingleValueFilter(filterName, variableName, useVariableName = '', comparator = 'equals') { setSingleValueFilter(filterName, variableName, useVariableName = '', comparator = 'equals') {

View File

@ -15,19 +15,21 @@
<div <div
class="list-background background-fade-in" class="list-background background-fade-in"
:class="{'is-visible': background}" :class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : false}"></div> :style="{'background-image': background !== null ? `url(${background})` : undefined}"
/>
<div class="list-content"> <div class="list-content">
<div class="is-archived-container">
<span class="is-archived" v-if="list.isArchived"> <span class="is-archived" v-if="list.isArchived">
{{ $t('namespace.archived') }} {{ $t('namespace.archived') }}
</span> </span>
<span <BaseButton
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}" v-else
:class="{'is-favorite': list.isFavorite}"
@click.stop="toggleFavoriteList(list)" @click.stop="toggleFavoriteList(list)"
class="favorite"> class="favorite"
>
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/> <icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/>
</span> </BaseButton>
</div>
<div class="title">{{ list.title }}</div> <div class="title">{{ list.title }}</div>
</div> </div>
</router-link> </router-link>
@ -43,6 +45,8 @@ import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {colorIsDark} from '@/helpers/color/colorIsDark' import {colorIsDark} from '@/helpers/color/colorIsDark'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import BaseButton from '@/components/base/BaseButton.vue'
const background = ref<string | null>(null) const background = ref<string | null>(null)
const backgroundLoading = ref(false) const backgroundLoading = ref(false)
const blurHashUrl = ref('') const blurHashUrl = ref('')
@ -109,13 +113,14 @@ function toggleFavoriteList(list: ListModel) {
color: var(--grey-100); color: var(--grey-100);
} }
&.has-background, .list-background { &.has-background,
.list-background {
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; background-position: center;
} }
&.has-background .list-content .title { &.has-background .title {
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700); text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
color: var(--white); color: var(--white);
} }
@ -176,21 +181,33 @@ function toggleFavoriteList(list: ListModel) {
.list-content { .list-content {
display: flex; display: flex;
justify-content: space-between; align-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
padding: 1rem; padding: 1rem;
position: absolute; position: absolute;
height: 100%; height: 100%;
width: 100%; width: 100%;
.is-archived-container {
width: 100%;
text-align: right;
.is-archived { .is-archived {
font-size: .75rem; font-size: .75rem;
float: left;
} }
.favorite {
margin-left: auto;
transition: opacity $transition, color $transition;
opacity: 0;
display: block;
&:hover,
&.is-favorite {
color: var(--warning);
}
}
.favorite.is-favorite,
&:hover .favorite {
opacity: 1;
} }
.title { .title {
@ -209,30 +226,6 @@ function toggleFavoriteList(list: ListModel) {
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.favorite {
transition: opacity $transition, color $transition;
opacity: 0;
&:hover {
color: var(--warning);
}
&.is-archived {
display: none;
}
&.is-favorite {
display: inline-block;
opacity: 1;
color: var(--warning);
}
}
&:hover .favorite {
opacity: 1;
}
} }
} }
</style> </style>

View File

@ -0,0 +1,17 @@
<template>
<BaseButton class="button-link"><slot/></BaseButton>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
</script>
<style lang="scss">
.button-link {
color: var(--link);
&:hover {
color: var(--link-hover);
}
}
</style>

View File

@ -27,7 +27,7 @@
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span> <span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
</i18n-t> </i18n-t>
<br/> <br/>
<a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a> <ButtonLink class="api-config__change-button" @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</ButtonLink>
</div> </div>
<message variant="success" v-if="successMsg !== '' && errorMsg === ''" class="mt-2"> <message variant="success" v-if="successMsg !== '' && errorMsg === ''" class="mt-2">
@ -48,6 +48,7 @@ import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
import {success} from '@/message' import {success} from '@/message'
import Message from '@/components/misc/message.vue' import Message from '@/components/misc/message.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
const props = defineProps({ const props = defineProps({
configureOpen: { configureOpen: {
@ -117,4 +118,9 @@ async function setApiUrl() {
.url { .url {
border-bottom: 1px dashed var(--primary); border-bottom: 1px dashed var(--primary);
} }
.api-config__change-button {
display: inline-block;
color: var(--link);
}
</style> </style>

View File

@ -4,7 +4,7 @@
<p class="card-header-title"> <p class="card-header-title">
{{ title }} {{ title }}
</p> </p>
<a <BaseButton
v-if="hasClose" v-if="hasClose"
class="card-header-icon" class="card-header-icon"
:aria-label="$t('misc.close')" :aria-label="$t('misc.close')"
@ -14,7 +14,7 @@
<span class="icon"> <span class="icon">
<icon :icon="closeIcon"/> <icon :icon="closeIcon"/>
</span> </span>
</a> </BaseButton>
</header> </header>
<div class="card-content loader-container" :class="{'p-0': !padding, 'is-loading': loading}"> <div class="card-content loader-container" :class="{'p-0': !padding, 'is-loading': loading}">
<div :class="{'content': hasContent}"> <div :class="{'content': hasContent}">
@ -25,6 +25,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
defineProps({ defineProps({
title: { title: {
type: String, type: String,

View File

@ -1,14 +1,15 @@
<template> <template>
<message variant="danger"> <message variant="danger">
<i18n-t keypath="loadingError.failed"> <i18n-t keypath="loadingError.failed">
<a @click="reload">{{ $t('loadingError.tryAgain') }}</a> <ButtonLink @click="reload">{{ $t('loadingError.tryAgain') }}</ButtonLink>
<a href="https://vikunja.io/contact/" rel="noreferrer noopener nofollow" target="_blank">{{ $t('loadingError.contact') }}</a> <ButtonLink href="https://vikunja.io/contact/">{{ $t('loadingError.contact') }}</ButtonLink>
</i18n-t> </i18n-t>
</message> </message>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import Message from '@/components/misc/message.vue' import Message from '@/components/misc/message.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
function reload() { function reload() {
window.location.reload() window.location.reload()

View File

@ -1,4 +1,4 @@
import {RouteLocation} from 'vue-router' import type {RouteLocation} from 'vue-router'
import {isAppleDevice} from '@/helpers/isAppleDevice' import {isAppleDevice} from '@/helpers/isAppleDevice'

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="legal-links"> <div class="legal-links">
<a :href="imprintUrl" rel="noreferrer noopener nofollow" target="_blank" v-if="imprintUrl">{{ $t('navigation.imprint') }}</a> <BaseButton :href="imprintUrl" v-if="imprintUrl">{{ $t('navigation.imprint') }}</BaseButton>
<span v-if="imprintUrl && privacyPolicyUrl"> | </span> <span v-if="imprintUrl && privacyPolicyUrl"> | </span>
<a :href="privacyPolicyUrl" rel="noreferrer noopener nofollow" target="_blank" v-if="privacyPolicyUrl">{{ $t('navigation.privacy') }}</a> <BaseButton :href="privacyPolicyUrl" v-if="privacyPolicyUrl">{{ $t('navigation.privacy') }}</BaseButton>
</div> </div>
</template> </template>
@ -10,6 +10,8 @@
import {computed} from 'vue' import {computed} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import BaseButton from '@/components/base/BaseButton.vue'
const store = useStore() const store = useStore()
const imprintUrl = computed(() => store.state.config.legal.imprintUrl) const imprintUrl = computed(() => store.state.config.legal.imprintUrl)

View File

@ -10,6 +10,7 @@
variant, variant,
]" ]"
ref="modal" ref="modal"
v-bind="attrs"
> >
<div <div
class="modal-container" class="modal-container"
@ -62,11 +63,18 @@
</Teleport> </Teleport>
</template> </template>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<script lang="ts" setup> <script lang="ts" setup>
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import {onUnmounted, ref, watch} from 'vue' import {onUnmounted, ref, useAttrs, watch} from 'vue'
import {useScrollLock} from '@vueuse/core' import {useScrollLock} from '@vueuse/core'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
enabled?: boolean, enabled?: boolean,
overflow?: boolean, overflow?: boolean,
@ -81,6 +89,8 @@ const props = withDefaults(defineProps<{
defineEmits(['close', 'submit']) defineEmits(['close', 'submit'])
const attrs = useAttrs()
const modal = ref<HTMLElement | null>(null) const modal = ref<HTMLElement | null>(null)
const scrollLock = useScrollLock(modal) const scrollLock = useScrollLock(modal)

View File

@ -1,6 +1,7 @@
<template> <template>
<notifications position="bottom left" :max="2" class="global-notification"> <notifications position="bottom left" :max="2" class="global-notification">
<template #body="{ item, close }"> <template #body="{ item, close }">
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
<div <div
:class="[ :class="[
'vue-notification-template', 'vue-notification-template',

View File

@ -1,10 +1,10 @@
<template> <template>
<div class="notifications"> <div class="notifications">
<div class="is-flex is-justify-content-center"> <div class="is-flex is-justify-content-center">
<a @click.stop="showNotifications = !showNotifications" class="trigger-button"> <BaseButton @click.stop="showNotifications = !showNotifications" class="trigger-button">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span> <span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/> <icon icon="bell"/>
</a> </BaseButton>
</div> </div>
<transition name="fade"> <transition name="fade">
@ -26,9 +26,9 @@
<span class="has-text-weight-bold mr-1" v-if="n.notification.doer"> <span class="has-text-weight-bold mr-1" v-if="n.notification.doer">
{{ n.notification.doer.getDisplayName() }} {{ n.notification.doer.getDisplayName() }}
</span> </span>
<a @click="() => to(n, index)()"> <BaseButton @click="() => to(n, index)()">
{{ n.toText(userInfo) }} {{ n.toText(userInfo) }}
</a> </BaseButton>
</div> </div>
<span class="created" v-tooltip="formatDate(n.created)"> <span class="created" v-tooltip="formatDate(n.created)">
{{ formatDateSince(n.created) }} {{ formatDateSince(n.created) }}
@ -50,6 +50,7 @@
import {computed, onMounted, onUnmounted, ref} from 'vue' import {computed, onMounted, onUnmounted, ref} from 'vue'
import NotificationService from '@/services/notification' import NotificationService from '@/services/notification'
import BaseButton from '@/components/base/BaseButton.vue'
import User from '@/components/misc/user.vue' import User from '@/components/misc/user.vue'
import names from '@/models/constants/notificationNames.json' import names from '@/models/constants/notificationNames.json'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside' import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'

View File

@ -32,7 +32,7 @@
{{ r.title }} {{ r.title }}
</span> </span>
<div class="result-items"> <div class="result-items">
<button <BaseButton
v-for="(i, key) in r.items" v-for="(i, key) in r.items"
:key="key" :key="key"
:ref="`result-${k}_${key}`" :ref="`result-${k}_${key}`"
@ -44,7 +44,7 @@
:class="{'is-strikethrough': i.done}" :class="{'is-strikethrough': i.done}"
> >
{{ i.title }} {{ i.title }}
</button> </BaseButton>
</div> </div>
</div> </div>
</div> </div>
@ -63,6 +63,8 @@ import TeamModel from '@/models/team'
import {CURRENT_LIST, LOADING, LOADING_MODULE, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types' import {CURRENT_LIST, LOADING, LOADING_MODULE, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue' import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import {getHistory} from '@/modules/listHistory' import {getHistory} from '@/modules/listHistory'
import {parseTaskText, PrefixMode} from '@/modules/parseTaskText' import {parseTaskText, PrefixMode} from '@/modules/parseTaskText'
@ -86,7 +88,10 @@ const SEARCH_MODE_TEAMS = 'teams'
export default defineComponent({ export default defineComponent({
name: 'quick-actions', name: 'quick-actions',
components: {QuickAddMagic}, components: {
BaseButton,
QuickAddMagic,
},
data() { data() {
return { return {
query: '', query: '',

View File

@ -16,7 +16,7 @@
class="month" class="month"
v-for="(m, mk) in days[yk]" v-for="(m, mk) in days[yk]"
> >
{{ formatYear(new Date(`${yk}-${parseInt(mk) + 1}-01`)) }} {{ formatMonthAndYear(yk, parseInt(mk) + 1) }}
<div class="days"> <div class="days">
<div <div
:class="{ today: d.toDateString() === now.toDateString() }" :class="{ today: d.toDateString() === now.toDateString() }"
@ -96,9 +96,10 @@
</span> </span>
<priority-label :priority="t.priority" :done="t.done"/> <priority-label :priority="t.priority" :done="t.done"/>
<!-- using the key here forces vue to use the updated version model and not the response returned by the api --> <!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
<a @click="editTask(theTasks[k])" class="edit-toggle"> <!-- FIXME: add label -->
<BaseButton @click="editTask(theTasks[k])" class="edit-toggle">
<icon icon="pen"/> <icon icon="pen"/>
</a> </BaseButton>
</VueDragResize> </VueDragResize>
</div> </div>
<template v-if="showTaskswithoutDates"> <template v-if="showTaskswithoutDates">
@ -184,12 +185,14 @@ import TaskCollectionService from '../../services/taskCollection'
import {mapState} from 'vuex' import {mapState} from 'vuex'
import Rights from '../../models/constants/rights.json' import Rights from '../../models/constants/rights.json'
import FilterPopup from '@/components/list/partials/filter-popup.vue' import FilterPopup from '@/components/list/partials/filter-popup.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {colorIsDark} from '@/helpers/color/colorIsDark' import {colorIsDark} from '@/helpers/color/colorIsDark'
export default defineComponent({ export default defineComponent({
name: 'GanttChart', name: 'GanttChart',
components: { components: {
BaseButton,
FilterPopup, FilterPopup,
PriorityLabel, PriorityLabel,
EditTask, EditTask,
@ -433,7 +436,9 @@ export default defineComponent({
this.newTaskTitle = '' this.newTaskTitle = ''
this.hideCrateNewTask() this.hideCrateNewTask()
}, },
formatYear(date) { formatMonthAndYear(year, month) {
month = month < 10 ? '0' + month : month
const date = new Date(`${year}-${month}-01`)
return this.format(date, 'MMMM, yyyy') return this.format(date, 'MMMM, yyyy')
}, },
}, },

View File

@ -26,6 +26,9 @@
</progress> </progress>
<div class="files" v-if="attachments.length > 0"> <div class="files" v-if="attachments.length > 0">
<!-- FIXME: don't use a for element that wraps other links / buttons
Instead: overlay element with button that is inside.
-->
<a <a
class="attachment" class="attachment"
v-for="a in attachments" v-for="a in attachments"
@ -53,25 +56,28 @@
</span> </span>
</p> </p>
<p> <p>
<a <BaseButton
class="attachment-info-meta-button"
@click.prevent.stop="downloadAttachment(a)" @click.prevent.stop="downloadAttachment(a)"
v-tooltip="$t('task.attachment.downloadTooltip')" v-tooltip="$t('task.attachment.downloadTooltip')"
> >
{{ $t('misc.download') }} {{ $t('misc.download') }}
</a> </BaseButton>
<a <BaseButton
class="attachment-info-meta-button"
@click.stop="copyUrl(a)" @click.stop="copyUrl(a)"
v-tooltip="$t('task.attachment.copyUrlTooltip')" v-tooltip="$t('task.attachment.copyUrlTooltip')"
> >
{{ $t('task.attachment.copyUrl') }} {{ $t('task.attachment.copyUrl') }}
</a> </BaseButton>
<a <BaseButton
@click.prevent.stop="() => {attachmentToDelete = a; showDeleteModal = true}"
v-if="editEnabled" v-if="editEnabled"
class="attachment-info-meta-button"
@click.prevent.stop="() => {attachmentToDelete = a; showDeleteModal = true}"
v-tooltip="$t('task.attachment.deleteTooltip')" v-tooltip="$t('task.attachment.deleteTooltip')"
> >
{{ $t('misc.delete') }} {{ $t('misc.delete') }}
</a> </BaseButton>
</p> </p>
</div> </div>
</a> </a>
@ -148,9 +154,12 @@ import {mapState} from 'vuex'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { uploadFiles, generateAttachmentUrl } from '@/helpers/attachments' import { uploadFiles, generateAttachmentUrl } from '@/helpers/attachments'
import BaseButton from '@/components/base/BaseButton'
export default defineComponent({ export default defineComponent({
name: 'attachments', name: 'attachments',
components: { components: {
BaseButton,
User, User,
}, },
data() { data() {
@ -297,7 +306,7 @@ export default defineComponent({
display: flex; display: flex;
> span:not(:last-child):after, > span:not(:last-child):after,
> a:not(:last-child):after { > button:not(:last-child):after {
content: '·'; content: '·';
padding: 0 .25rem; padding: 0 .25rem;
} }
@ -377,7 +386,7 @@ export default defineComponent({
} }
> span:not(:last-child):after, > span:not(:last-child):after,
> a:not(:last-child):after { > button:not(:last-child):after {
display: none; display: none;
} }
@ -387,6 +396,10 @@ export default defineComponent({
} }
} }
.attachment-info-meta-button {
color: var(--link);
}
@keyframes bounce { @keyframes bounce {
from, from,
20%, 20%,

View File

@ -18,9 +18,9 @@
<template #tag="{item: user}"> <template #tag="{item: user}">
<span class="assignee"> <span class="assignee">
<user :avatar-size="32" :show-username="false" :user="user"/> <user :avatar-size="32" :show-username="false" :user="user"/>
<a @click="removeAssignee(user)" class="remove-assignee" v-if="!disabled"> <BaseButton @click="removeAssignee(user)" class="remove-assignee" v-if="!disabled">
<icon icon="times"/> <icon icon="times"/>
</a> </BaseButton>
</span> </span>
</template> </template>
</Multiselect> </Multiselect>
@ -34,6 +34,7 @@ import {useI18n} from 'vue-i18n'
import User from '@/components/misc/user.vue' import User from '@/components/misc/user.vue'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {includesById} from '@/helpers/utils' import {includesById} from '@/helpers/utils'
import UserModel from '@/models/user' import UserModel from '@/models/user'
@ -95,7 +96,7 @@ async function removeAssignee(user: UserModel) {
success({message: t('task.assignee.unassignSuccess')}) success({message: t('task.assignee.unassignSuccess')})
} }
async function findUser(query) { async function findUser(query: string) {
if (query === '') { if (query === '') {
clearAllFoundUsers() clearAllFoundUsers()
return return

View File

@ -19,7 +19,7 @@
:style="{'background': label.hexColor, 'color': label.textColor}" :style="{'background': label.hexColor, 'color': label.textColor}"
class="tag"> class="tag">
<span>{{ label.title }}</span> <span>{{ label.title }}</span>
<button type="button" v-cy="'taskDetail.removeLabel'" @click="removeLabel(label)" class="delete is-small" /> <BaseButton v-cy="'taskDetail.removeLabel'" @click="removeLabel(label)" class="delete is-small" />
</span> </span>
</template> </template>
<template #searchResult="{option}"> <template #searchResult="{option}">
@ -47,6 +47,7 @@ import LabelModel from '@/models/label'
import LabelTaskService from '@/services/labelTask' import LabelTaskService from '@/services/labelTask'
import {success} from '@/message' import {success} from '@/message'
import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
const props = defineProps({ const props = defineProps({

View File

@ -4,9 +4,9 @@
:class="{ :class="{
'is-loading': loadingInternal || loading, 'is-loading': loadingInternal || loading,
'draggable': !(loadingInternal || loading), 'draggable': !(loadingInternal || loading),
'has-light-text': task.getHexColor() !== TASK_DEFAULT_COLOR && !colorIsDark(task.getHexColor()), 'has-light-text': color !== TASK_DEFAULT_COLOR && !colorIsDark(color),
}" }"
:style="{'background-color': task.hexColor !== '#' && task.hexColor !== '' ? task.hexColor : false}" :style="{'background-color': color !== TASK_DEFAULT_COLOR ? color : false}"
@click.exact="openTaskDetail()" @click.exact="openTaskDetail()"
@click.ctrl="() => toggleTaskDone(task)" @click.ctrl="() => toggleTaskDone(task)"
@click.meta="() => toggleTaskDone(task)" @click.meta="() => toggleTaskDone(task)"
@ -103,6 +103,13 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
computed: {
color() {
return this.task.getHexColor
? this.task.getHexColor()
: TASK_DEFAULT_COLOR
},
},
methods: { methods: {
colorIsDark, colorIsDark,
async toggleTaskDone(task) { async toggleTaskDone(task) {

View File

@ -2,7 +2,7 @@
<div v-if="available"> <div v-if="available">
<p class="help has-text-grey"> <p class="help has-text-grey">
{{ $t('task.quickAddMagic.hint') }}. {{ $t('task.quickAddMagic.hint') }}.
<a @click="() => visible = true">{{ $t('task.quickAddMagic.what') }}</a> <ButtonLink @click="() => visible = true">{{ $t('task.quickAddMagic.what') }}</ButtonLink>
</p> </p>
<modal <modal
@close="() => visible = false" @close="() => visible = false"
@ -42,6 +42,10 @@
{{ $t('task.quickAddMagic.list1', {prefix: prefixes.list}) }} {{ $t('task.quickAddMagic.list1', {prefix: prefixes.list}) }}
{{ $t('task.quickAddMagic.list2') }} {{ $t('task.quickAddMagic.list2') }}
</p> </p>
<p>
{{ $t('task.quickAddMagic.list3') }}
{{ $t('task.quickAddMagic.list4', {prefix: prefixes.list}) }}
</p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3> <h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>
<p> <p>
@ -86,9 +90,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, computed} from 'vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode' import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {PREFIXES} from '@/modules/parseTaskText' import {PREFIXES} from '@/modules/parseTaskText'
import {ref, computed} from 'vue'
const visible = ref(false) const visible = ref(false)
const mode = ref(getQuickAddMagicMode()) const mode = ref(getQuickAddMagicMode())

View File

@ -35,6 +35,7 @@
:creatable="true" :creatable="true"
:create-placeholder="$t('task.relation.createPlaceholder')" :create-placeholder="$t('task.relation.createPlaceholder')"
@create="createAndRelateTask" @create="createAndRelateTask"
@select="addTaskRelation"
> >
<template #searchResult="props"> <template #searchResult="props">
<span v-if="typeof props.option !== 'string'" class="search-result"> <span v-if="typeof props.option !== 'string'" class="search-result">
@ -103,12 +104,13 @@
</span> </span>
{{ t.title }} {{ t.title }}
</router-link> </router-link>
<a <BaseButton
v-if="editEnabled"
@click="() => {showDeleteModal = true; relationToDelete = {relationKind: rts.kind, otherTaskId: t.id}}" @click="() => {showDeleteModal = true; relationToDelete = {relationKind: rts.kind, otherTaskId: t.id}}"
class="remove" class="remove"
v-if="editEnabled"> >
<icon icon="trash-alt"/> <icon icon="trash-alt"/>
</a> </BaseButton>
</div> </div>
</div> </div>
</div> </div>
@ -145,6 +147,7 @@ import TaskRelationService from '../../../services/taskRelation'
import relationKinds from '../../../models/constants/relationKinds' import relationKinds from '../../../models/constants/relationKinds'
import TaskRelationModel from '../../../models/taskRelation' import TaskRelationModel from '../../../models/taskRelation'
import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
export default defineComponent({ export default defineComponent({
@ -166,6 +169,7 @@ export default defineComponent({
} }
}, },
components: { components: {
BaseButton,
Multiselect, Multiselect,
}, },
props: { props: {

View File

@ -11,9 +11,9 @@
:disabled="disabled" :disabled="disabled"
@close-on-change="() => addReminderDate(index)" @close-on-change="() => addReminderDate(index)"
/> />
<a @click="removeReminderByIndex(index)" v-if="!disabled" class="remove"> <BaseButton @click="removeReminderByIndex(index)" v-if="!disabled" class="remove">
<icon icon="times"></icon> <icon icon="times"></icon>
</a> </BaseButton>
</div> </div>
<div class="reminder-input" v-if="!disabled"> <div class="reminder-input" v-if="!disabled">
<Datepicker <Datepicker
@ -28,10 +28,13 @@
<script setup lang="ts"> <script setup lang="ts">
import {PropType, ref, onMounted, watch} from 'vue' import {PropType, ref, onMounted, watch} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import Datepicker from '@/components/input/datepicker.vue' import Datepicker from '@/components/input/datepicker.vue'
type Reminder = Date | string type Reminder = Date | string
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Array as PropType<Reminder[]>, type: Array as PropType<Reminder[]>,
@ -115,7 +118,7 @@ function removeReminderByIndex(index: number) {
display: flex; display: flex;
align-items: center; align-items: center;
&.overdue :deep(.datepicker a.show) { &.overdue :deep(.datepicker .show) {
color: var(--danger); color: var(--danger);
} }
@ -123,7 +126,7 @@ function removeReminderByIndex(index: number) {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
a.remove { .remove {
color: var(--danger); color: var(--danger);
padding-left: .5rem; padding-left: .5rem;
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div :class="{'is-loading': taskService.loading}" class="task loader-container"> <div :class="{'is-loading': taskService.loading}" class="task loader-container">
<fancycheckbox :disabled="isArchived || disabled" @change="markAsDone" v-model="task.done"/> <fancycheckbox :disabled="(isArchived || disabled) && !canMarkAsDone" @change="markAsDone" v-model="task.done"/>
<span <span
v-if="showListColor && listColor !== ''" v-if="showListColor && listColor !== ''"
:style="{backgroundColor: listColor }" :style="{backgroundColor: listColor }"
@ -39,17 +39,21 @@
:user="a" :user="a"
v-for="(a, i) in task.assignees" v-for="(a, i) in task.assignees"
/> />
<BaseButton
v-if="+new Date(task.dueDate) > 0"
class="dueDate"
@click.prevent.stop="showDefer = !showDefer"
v-tooltip="formatDate(task.dueDate)"
>
<time <time
:datetime="formatISO(task.dueDate)" :datetime="formatISO(task.dueDate)"
:class="{'overdue': task.dueDate <= new Date() && !task.done}" :class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="is-italic" class="is-italic"
@click.prevent.stop="showDefer = !showDefer"
v-if="+new Date(task.dueDate) > 0"
v-tooltip="formatDate(task.dueDate)"
:aria-expanded="showDefer ? 'true' : 'false'" :aria-expanded="showDefer ? 'true' : 'false'"
> >
- {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }} - {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }}
</time> </time>
</BaseButton>
<transition name="fade"> <transition name="fade">
<defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/> <defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/>
</transition> </transition>
@ -80,13 +84,13 @@
v-tooltip="$t('task.detail.belongsToList', {list: $store.getters['lists/getListById'](task.listId).title})"> v-tooltip="$t('task.detail.belongsToList', {list: $store.getters['lists/getListById'](task.listId).title})">
{{ $store.getters['lists/getListById'](task.listId).title }} {{ $store.getters['lists/getListById'](task.listId).title }}
</router-link> </router-link>
<a <BaseButton
:class="{'is-favorite': task.isFavorite}" :class="{'is-favorite': task.isFavorite}"
@click="toggleFavorite" @click="toggleFavorite"
class="favorite"> class="favorite">
<icon icon="star" v-if="task.isFavorite"/> <icon icon="star" v-if="task.isFavorite"/>
<icon :icon="['far', 'star']" v-else/> <icon :icon="['far', 'star']" v-else/>
</a> </BaseButton>
<slot></slot> <slot></slot>
</div> </div>
</template> </template>
@ -99,6 +103,7 @@ import PriorityLabel from './priorityLabel'
import TaskService from '../../../services/task' import TaskService from '../../../services/task'
import Labels from './labels' import Labels from './labels'
import User from '../../misc/user' import User from '../../misc/user'
import BaseButton from '@/components/base/BaseButton.vue'
import Fancycheckbox from '../../input/fancycheckbox' import Fancycheckbox from '../../input/fancycheckbox'
import DeferTask from './defer-task' import DeferTask from './defer-task'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside' import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
@ -115,6 +120,7 @@ export default defineComponent({
} }
}, },
components: { components: {
BaseButton,
ChecklistSummary, ChecklistSummary,
DeferTask, DeferTask,
Fancycheckbox, Fancycheckbox,
@ -143,6 +149,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
canMarkAsDone: {
type: Boolean,
default: true,
},
}, },
emits: ['task-updated'], emits: ['task-updated'],
watch: { watch: {
@ -249,6 +259,11 @@ export default defineComponent({
display: inline-block; display: inline-block;
flex: 1 0 50%; flex: 1 0 50%;
.dueDate {
display: inline-block;
margin-left: 5px;
}
.overdue { .overdue {
color: var(--danger); color: var(--danger);
} }

View File

@ -20,14 +20,13 @@ const SORT_BY_DEFAULT = {
/** /**
* This mixin provides a base set of methods and properties to get tasks on a list. * This mixin provides a base set of methods and properties to get tasks on a list.
*/ */
export function useTaskList(listId) { export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
const params = ref({...getDefaultParams()}) const params = ref({...getDefaultParams()})
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
const sortBy = ref({ ...SORT_BY_DEFAULT }) const sortBy = ref({ ...sortByDefault })
// This makes sure an id sort order is always sorted last. // This makes sure an id sort order is always sorted last.
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes // When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
@ -108,5 +107,6 @@ export function useTaskList(listId) {
loadTasks, loadTasks,
searchTerm: search, searchTerm: search,
params, params,
sortByParam: sortBy,
} }
} }

View File

@ -1,5 +1,6 @@
import {computed, watch, readonly} from 'vue' import {computed, watch, readonly} from 'vue'
import {useStorage, createSharedComposable, BasicColorSchema, usePreferredColorScheme, tryOnMounted} from '@vueuse/core' import {useStorage, createSharedComposable, usePreferredColorScheme, tryOnMounted} from '@vueuse/core'
import type {BasicColorSchema} from '@vueuse/core'
const STORAGE_KEY = 'color-scheme' const STORAGE_KEY = 'color-scheme'

View File

@ -1,4 +1,4 @@
import {ref, computed, Ref} from 'vue' import {ref, computed} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
export function useNameSpaceSearch() { export function useNameSpaceSearch() {

View File

@ -1,6 +1,6 @@
import {ref} from 'vue' import {ref} from 'vue'
import {useOnline as useNetworkOnline, ConfigurableWindow} from '@vueuse/core' import {useOnline as useNetworkOnline} from '@vueuse/core'
import type {ConfigurableWindow} from '@vueuse/core'
export function useOnline(options?: ConfigurableWindow) { export function useOnline(options?: ConfigurableWindow) {
const fakeOnlineState = !!import.meta.env.VITE_IS_ONLINE const fakeOnlineState = !!import.meta.env.VITE_IS_ONLINE

View File

@ -1,7 +1,7 @@
import { computed, watchEffect } from 'vue' import { computed, watchEffect } from 'vue'
import { setTitle } from '@/helpers/setTitle' import type { ComputedGetter } from 'vue'
import { ComputedGetter } from 'vue' import { setTitle } from '@/helpers/setTitle'
export function useTitle(titleGetter: ComputedGetter<string>) { export function useTitle(titleGetter: ComputedGetter<string>) {
const titleRef = computed(titleGetter) const titleRef = computed(titleGetter)

View File

@ -1,4 +1,4 @@
import {Directive} from 'vue' import type {Directive} from 'vue'
declare global { declare global {
interface Window { interface Window {

View File

@ -1,4 +1,4 @@
import {Directive} from 'vue' import type {Directive} from 'vue'
import {install, uninstall} from '@github/hotkey' import {install, uninstall} from '@github/hotkey'
import {isAppleDevice} from '@/helpers/isAppleDevice' import {isAppleDevice} from '@/helpers/isAppleDevice'

View File

@ -1,6 +1,6 @@
import {i18n} from '@/i18n' import {i18n} from '@/i18n'
import ListModal from '@/modals/list' import type ListModal from '@/models/list'
export function getListTitle(l: ListModal) { export function getListTitle(l: ListModal) {
if (l.id === -1) { if (l.id === -1) {

View File

@ -222,8 +222,8 @@ export const getDateFromTextIn = (text: string, now: Date = new Date()) => {
} }
const getDateFromWeekday = (text: string): dateFoundResult => { const getDateFromWeekday = (text: string): dateFoundResult => {
const matcher: RegExp = / (next )?(monday|mon|tuesday|tue|wednesday|wed|thursday|thu|friday|fri|saturday|sat|sunday|sun)($| )/ig const matcher: RegExp = / (next )?(monday|mon|tuesday|tue|wednesday|wed|thursday|thu|friday|fri|saturday|sat|sunday|sun)($| )/g
const results: string[] | null = matcher.exec(text) const results: string[] | null = matcher.exec(text.toLowerCase()) // The i modifier does not seem to work.
if (results === null) { if (results === null) {
return { return {
foundText: null, foundText: null,

View File

@ -793,13 +793,15 @@
"multiple": "You can use this multiple times.", "multiple": "You can use this multiple times.",
"label1": "To add a label, simply prefix the name of the label with {prefix}.", "label1": "To add a label, simply prefix the name of the label with {prefix}.",
"label2": "Vikunja will first check if the label already exist and create it if not.", "label2": "Vikunja will first check if the label already exist and create it if not.",
"label3": "To use spaces, simply add a \" around the label name.", "label3": "To use spaces, simply add a \" or ' around the label name.",
"label4": "For example: {prefix}\"Label with spaces\".", "label4": "For example: {prefix}\"Label with spaces\".",
"priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.", "priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.",
"priority2": "The higher the number, the higher the priority.", "priority2": "The higher the number, the higher the priority.",
"assignees": "To directly assign the task to a user, add their username prefixed with {prefix} to the task.", "assignees": "To directly assign the task to a user, add their username prefixed with {prefix} to the task.",
"list1": "To set a list for the task to appear in, enter its name prefixed with {prefix}.", "list1": "To set a list for the task to appear in, enter its name prefixed with {prefix}.",
"list2": "This will return an error if the list does not exist.", "list2": "This will return an error if the list does not exist.",
"list3": "To use spaces, simply add a \" or ' around the list name.",
"list4": "For example: {prefix}\"List with spaces\".",
"dateAndTime": "Date and time", "dateAndTime": "Date and time",
"date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:", "date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:",
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",

View File

@ -77,7 +77,7 @@
"newName": "Il nuovo nome", "newName": "Il nuovo nome",
"savedSuccess": "Impostazioni salvate con successo.", "savedSuccess": "Impostazioni salvate con successo.",
"emailReminders": "Inviami promemoria per le attività via e-mail", "emailReminders": "Inviami promemoria per le attività via e-mail",
"overdueReminders": "Send me a summary of my undone overdue tasks every day", "overdueReminders": "Inviami ogni giorno un riassunto delle attività in ritardo",
"discoverableByName": "Lascia che altri utenti mi trovino cercando il mio nome", "discoverableByName": "Lascia che altri utenti mi trovino cercando il mio nome",
"discoverableByEmail": "Lascia che altri utenti mi trovino quando cercano il mio indirizzo e-mail completo", "discoverableByEmail": "Lascia che altri utenti mi trovino quando cercano il mio indirizzo e-mail completo",
"playSoundWhenDone": "Riproduci un suono quando le attività vengono segnate come fatte", "playSoundWhenDone": "Riproduci un suono quando le attività vengono segnate come fatte",
@ -87,7 +87,7 @@
"language": "Lingua", "language": "Lingua",
"defaultList": "Lista predefinita", "defaultList": "Lista predefinita",
"timezone": "Fuso Orario", "timezone": "Fuso Orario",
"overdueTasksRemindersTime": "Overdue tasks reminder email time" "overdueTasksRemindersTime": "Orario email del promemoria attività in ritardo"
}, },
"totp": { "totp": {
"title": "Autenticazione a due fattori", "title": "Autenticazione a due fattori",
@ -728,16 +728,16 @@
"removeSuccess": "Etichetta eliminata.", "removeSuccess": "Etichetta eliminata.",
"addCreateSuccess": "Etichetta creata e aggiunta.", "addCreateSuccess": "Etichetta creata e aggiunta.",
"delete": { "delete": {
"header": "Delete this label", "header": "Elimina etichetta",
"text1": "Are you sure you want to delete this label?", "text1": "Sei sicuro di voler eliminare questa etichetta?",
"text2": "This will remove it from all tasks and cannot be restored." "text2": "Verrà rimossa da tutte le attività e non potrà essere ripristinata."
} }
}, },
"priority": { "priority": {
"unset": "Azzera", "unset": "Azzera",
"low": "Bassa", "low": "Bassa",
"medium": "Media", "medium": "Media",
"high": "High", "high": "Alta",
"urgent": "Urgente", "urgent": "Urgente",
"doNow": "FARE ORA" "doNow": "FARE ORA"
}, },

View File

@ -1,4 +1,4 @@
import {Document, SimpleDocumentSearchResultSetUnit} from 'flexsearch' import {Document} from 'flexsearch'
export interface withId { export interface withId {
id: number, id: number,

View File

@ -5,16 +5,8 @@ import router from './router'
import {error, success} from './message' import {error, success} from './message'
declare global {
interface Window {
API_URL: string;
SENTRY_ENABLED: boolean;
SENTRY_DSN: string;
}
}
import {formatDate, formatDateShort, formatDateLong, formatDateSince, formatISO} from '@/helpers/time/formatDate' import {formatDate, formatDateShort, formatDateLong, formatDateSince, formatISO} from '@/helpers/time/formatDate'
// @ts-ignore
import {VERSION} from './version.json' import {VERSION} from './version.json'
// Notifications // Notifications
@ -28,6 +20,14 @@ import {store} from './store'
// i18n // i18n
import {i18n} from './i18n' import {i18n} from './i18n'
declare global {
interface Window {
API_URL: string;
SENTRY_ENABLED: boolean;
SENTRY_DSN: string;
}
}
console.info(`Vikunja frontend version ${VERSION}`) console.info(`Vikunja frontend version ${VERSION}`)
// Check if we have an api url in local storage and use it if that's the case // Check if we have an api url in local storage and use it if that's the case
@ -95,7 +95,7 @@ app.config.errorHandler = (err, vm, info) => {
} }
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
app.config.warnHandler = (msg, vm, info) => { app.config.warnHandler = (msg) => {
error(msg) error(msg)
throw(msg) throw(msg)
} }

View File

@ -122,6 +122,18 @@ describe('Parse Task Text', () => {
expect(result.date.getMonth()).toBe(nextMonday.getMonth()) expect(result.date.getMonth()).toBe(nextMonday.getMonth())
expect(result.date.getDate()).toBe(nextMonday.getDate()) expect(result.date.getDate()).toBe(nextMonday.getDate())
}) })
it('should recognize next monday and ignore casing', () => {
const result = parseTaskText('Lorem Ipsum nExt Monday')
const untilNextMonday = calculateDayInterval('nextMonday')
expect(result.text).toBe('Lorem Ipsum')
const nextMonday = new Date()
nextMonday.setDate(nextMonday.getDate() + untilNextMonday)
expect(result.date.getFullYear()).toBe(nextMonday.getFullYear())
expect(result.date.getMonth()).toBe(nextMonday.getMonth())
expect(result.date.getDate()).toBe(nextMonday.getDate())
})
it('should recognize this weekend', () => { it('should recognize this weekend', () => {
const result = parseTaskText('Lorem Ipsum this weekend') const result = parseTaskText('Lorem Ipsum this weekend')
@ -201,14 +213,47 @@ describe('Parse Task Text', () => {
expect(result.date.getMonth()).toBe(date.getMonth()) expect(result.date.getMonth()).toBe(date.getMonth())
expect(result.date.getDate()).toBe(date.getDate()) expect(result.date.getDate()).toBe(date.getDate())
}) })
it('should recognize weekdays', () => {
const result = parseTaskText('Lorem Ipsum thu') const cases = {
'monday': 1,
'Monday': 1,
'mon': 1,
'Mon': 1,
'tuesday': 2,
'Tuesday': 2,
'tue': 2,
'Tue': 2,
'wednesday': 3,
'Wednesday': 3,
'wed': 3,
'Wed': 3,
'thursday': 4,
'Thursday': 4,
'thu': 4,
'Thu': 4,
'friday': 5,
'Friday': 5,
'fri': 5,
'Fri': 5,
'saturday': 6,
'Saturday': 6,
'sat': 6,
'Sat': 6,
'sunday': 7,
'Sunday': 7,
'sun': 7,
'Sun': 7,
}
for (const c in cases) {
it(`should recognize ${c} as weekday`, () => {
const result = parseTaskText(`Lorem Ipsum ${c}`)
expect(result.text).toBe('Lorem Ipsum') expect(result.text).toBe('Lorem Ipsum')
const nextThursday = new Date() const nextDate = new Date()
nextThursday.setDate(nextThursday.getDate() + ((4 + 7 - nextThursday.getDay()) % 7)) nextDate.setDate(nextDate.getDate() + ((cases[c] + 7 - nextDate.getDay()) % 7))
expect(`${result.date.getFullYear()}-${result.date.getMonth()}-${result.date.getDate()}`).toBe(`${nextThursday.getFullYear()}-${nextThursday.getMonth()}-${nextThursday.getDate()}`) expect(`${result.date.getFullYear()}-${result.date.getMonth()}-${result.date.getDate()}`).toBe(`${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`)
}) })
}
it('should recognize weekdays with time', () => { it('should recognize weekdays with time', () => {
const result = parseTaskText('Lorem Ipsum thu at 14:00') const result = parseTaskText('Lorem Ipsum thu at 14:00')

View File

@ -1,4 +1,5 @@
import { createRouter, createWebHistory, RouteLocation } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import type { RouteLocation } from 'vue-router'
import {saveLastVisited} from '@/helpers/saveLastVisited' import {saveLastVisited} from '@/helpers/saveLastVisited'
import {store} from '@/store' import {store} from '@/store'

12
src/types/cypress.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
import { mount } from 'cypress/vue'
type MountParams = Parameters<typeof mount>;
type OptionsParam = MountParams[1];
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}

View File

@ -1,5 +1,4 @@
// https://next.vuex.vuejs.org/guide/migrating-to-4-0-from-3-x.html#typescript-support // https://next.vuex.vuejs.org/guide/migrating-to-4-0-from-3-x.html#typescript-support
import { ComponentCustomProperties } from 'vue'
import { Store } from 'vuex' import { Store } from 'vuex'
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {

View File

@ -1 +0,0 @@
/// <reference types="vite-svg-loader" />

1
src/types/vite.d.ts vendored
View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -88,7 +88,7 @@ function useSavedFilter(listId) {
filter.value = new SavedFilterModel({id: savedFilterId}) filter.value = new SavedFilterModel({id: savedFilterId})
const response = await filterService.value.get(filter.value) const response = await filterService.value.get(filter.value)
response.filters = objectToSnakeCase(filter.value.filters) response.filters = objectToSnakeCase(response.filters)
filter.value = response filter.value = response
}, {immediate: true}) }, {immediate: true})
@ -97,7 +97,7 @@ function useSavedFilter(listId) {
const response = await filterService.value.update(filter.value) const response = await filterService.value.update(filter.value)
await store.dispatch('namespaces/loadNamespaces') await store.dispatch('namespaces/loadNamespaces')
success({message: t('filters.edit.success')}) success({message: t('filters.edit.success')})
response.filters = objectToSnakeCase(filter.value.filters) response.filters = objectToSnakeCase(response.filters)
filter.value = response filter.value = response
} }
@ -120,6 +120,7 @@ const {
} = useSavedFilter(listId) } = useSavedFilter(listId)
const router = useRouter() const router = useRouter()
async function saveSavedFilter() { async function saveSavedFilter() {
await save() await save()
router.back() router.back()

View File

@ -32,13 +32,13 @@
v-tooltip.bottom="$t('label.edit.forbidden')"> v-tooltip.bottom="$t('label.edit.forbidden')">
{{ l.title }} {{ l.title }}
</span> </span>
<a <BaseButton
:style="{'color': l.textColor}" :style="{'color': l.textColor}"
@click="editLabel(l)" @click="editLabel(l)"
v-else> v-else>
{{ l.title }} {{ l.title }}
</a> </BaseButton>
<a @click="showDeleteDialoge(l)" class="delete is-small" v-if="userInfo.id === l.createdBy.id"></a> <BaseButton @click="showDeleteDialoge(l)" class="delete is-small" v-if="userInfo.id === l.createdBy.id" />
</span> </span>
</div> </div>
<div class="column is-4" v-if="isLabelEdit"> <div class="column is-4" v-if="isLabelEdit">
@ -116,12 +116,14 @@ import {mapState} from 'vuex'
import LabelModel from '../../models/label' import LabelModel from '../../models/label'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types' import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import BaseButton from '@/components/base/BaseButton.vue'
import AsyncEditor from '@/components/input/AsyncEditor' import AsyncEditor from '@/components/input/AsyncEditor'
import ColorPicker from '@/components/input/colorPicker' import ColorPicker from '@/components/input/colorPicker'
export default defineComponent({ export default defineComponent({
name: 'ListLabels', name: 'ListLabels',
components: { components: {
BaseButton,
ColorPicker, ColorPicker,
editor: AsyncEditor, editor: AsyncEditor,
}, },

View File

@ -408,7 +408,6 @@ export default defineComponent({
// of the drop target works all the time. // of the drop target works all the time.
const bucketIndex = parseInt(e.to.dataset.bucketIndex) const bucketIndex = parseInt(e.to.dataset.bucketIndex)
const newBucket = this.buckets[bucketIndex] const newBucket = this.buckets[bucketIndex]
// HACK: // HACK:
@ -429,11 +428,28 @@ export default defineComponent({
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
const newTask = cloneDeep(task) // cloning the task to avoid vuex store mutations const newTask = cloneDeep(task) // cloning the task to avoid vuex store mutations
newTask.bucketId = newBucket.id, newTask.bucketId = newBucket.id
newTask.kanbanPosition = calculateItemPosition(taskBefore !== null ? taskBefore.kanbanPosition : null, taskAfter !== null ? taskAfter.kanbanPosition : null) newTask.kanbanPosition = calculateItemPosition(
taskBefore !== null ? taskBefore.kanbanPosition : null,
taskAfter !== null ? taskAfter.kanbanPosition : null,
)
try { try {
await this.$store.dispatch('tasks/update', newTask) await this.$store.dispatch('tasks/update', newTask)
// Make sure the first and second task don't both get position 0 assigned
if(newTaskIndex === 0 && taskAfter.kanbanPosition === 0) {
console.log('first', taskAfter.id, taskAfter.kanbanPosition)
const taskAfterAfter = newBucket.tasks[newTaskIndex + 2] ?? null
const newTaskAfter = cloneDeep(taskAfter) // cloning the task to avoid vuex store mutations
newTaskAfter.bucketId = newBucket.id
newTaskAfter.kanbanPosition = calculateItemPosition(
0,
taskAfterAfter !== null ? taskAfterAfter.kanbanPosition : null,
)
await this.$store.dispatch('tasks/update', newTaskAfter)
}
} finally { } finally {
this.taskUpdating[task.id] = false this.taskUpdating[task.id] = false
this.oneTaskUpdating = false this.oneTaskUpdating = false

View File

@ -54,7 +54,7 @@
> >
<card :padding="false" :has-content="false" class="has-overflow"> <card :padding="false" :has-content="false" class="has-overflow">
<template <template
v-if="!list.isArchived && canWrite && list.id > 0" v-if="!list.isArchived && canWrite"
> >
<add-task <add-task
@taskAdded="updateTaskList" @taskAdded="updateTaskList"
@ -65,9 +65,9 @@
<nothing v-if="ctaVisible && tasks.length === 0 && !loading"> <nothing v-if="ctaVisible && tasks.length === 0 && !loading">
{{ $t('list.list.empty') }} {{ $t('list.list.empty') }}
<a @click="focusNewTaskInput()"> <ButtonLink @click="focusNewTaskInput()">
{{ $t('list.list.newTaskCta') }} {{ $t('list.list.newTaskCta') }}
</a> </ButtonLink>
</nothing> </nothing>
<div class="tasks-container" :class="{ 'has-task-edit-open': isTaskEdit }"> <div class="tasks-container" :class="{ 'has-task-edit-open': isTaskEdit }">
@ -99,13 +99,13 @@
<span class="icon handle"> <span class="icon handle">
<icon icon="grip-lines"/> <icon icon="grip-lines"/>
</span> </span>
<div <BaseButton
@click="editTask(t.id)" @click="editTask(t.id)"
class="icon settings" class="icon settings"
v-if="!list.isArchived" v-if="!list.isArchived"
> >
<icon icon="pencil-alt"/> <icon icon="pencil-alt"/>
</div> </BaseButton>
</template> </template>
</single-task-in-list> </single-task-in-list>
</template> </template>
@ -134,10 +134,12 @@
<script lang="ts"> <script lang="ts">
import { ref, toRef, defineComponent } from 'vue' import { ref, toRef, defineComponent } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
import ListWrapper from './ListWrapper.vue' import ListWrapper from './ListWrapper.vue'
import EditTask from '@/components/tasks/edit-task' import EditTask from '@/components/tasks/edit-task.vue'
import AddTask from '@/components/tasks/add-task' import AddTask from '@/components/tasks/add-task.vue'
import SingleTaskInList from '@/components/tasks/partials/singleTaskInList' import SingleTaskInList from '@/components/tasks/partials/singleTaskInList.vue'
import { useTaskList } from '@/composables/taskList' import { useTaskList } from '@/composables/taskList'
import Rights from '../../models/constants/rights.json' import Rights from '../../models/constants/rights.json'
import FilterPopup from '@/components/list/partials/filter-popup.vue' import FilterPopup from '@/components/list/partials/filter-popup.vue'
@ -190,6 +192,7 @@ export default defineComponent({
} }
}, },
components: { components: {
BaseButton,
ListWrapper, ListWrapper,
Nothing, Nothing,
FilterPopup, FilterPopup,
@ -198,6 +201,7 @@ export default defineComponent({
AddTask, AddTask,
draggable, draggable,
Pagination, Pagination,
ButtonLink,
}, },
setup(props) { setup(props) {
@ -210,7 +214,9 @@ export default defineComponent({
// isTaskEdit.value = false // isTaskEdit.value = false
// } // }
const taskList = useTaskList(toRef(props, 'listId')) const taskList = useTaskList(toRef(props, 'listId'), {
position: 'asc',
})
return { return {
taskEditTask, taskEditTask,

Some files were not shown because too many files have changed in this diff Show More