Compare commits

..

60 Commits

Author SHA1 Message Date
J. Lavoie 69590246df
Translated using Weblate (French)
Currently translated at 100.0% (666 of 666 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-07-12 20:52:52 +02:00
J. Lavoie b203e4a169
Translated using Weblate (German)
Currently translated at 96.6% (644 of 666 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-07-12 20:52:51 +02:00
J. Lavoie c5f8ed629a
Translated using Weblate (French)
Currently translated at 100.0% (666 of 666 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-07-10 12:40:09 +02:00
J. Lavoie 08ed54ab4e
Translated using Weblate (German)
Currently translated at 93.9% (626 of 666 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-07-10 12:40:06 +02:00
kolaente 2370115c35
Format 2021-07-09 10:31:30 +02:00
kolaente 86ca6c29c5
Merge branch 'main' into translations
# Conflicts:
#	src/i18n/lang/en.json
2021-07-09 10:31:13 +02:00
Andrey Kashlak f6b4b44743
Translated using Weblate (Russian)
Currently translated at 92.6% (581 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-07-09 09:56:24 +02:00
Sergio b54fae513a
Translated using Weblate (Spanish)
Currently translated at 8.6% (54 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/es/
2021-07-09 09:56:23 +02:00
Jesse James Isler d350b02aca
Translated using Weblate (German)
Currently translated at 100.0% (627 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-07-09 09:56:23 +02:00
Jesse James Isler 1238528e2b
Translated using Weblate (English)
Currently translated at 98.7% (619 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/en/
2021-07-09 09:56:12 +02:00
Andrey Kashlak 8de194bc06
Translated using Weblate (English)
Currently translated at 98.7% (619 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/en/
2021-07-09 09:56:12 +02:00
Andrey Kashlak 9800e1701d
Translated using Weblate (Russian)
Currently translated at 90.1% (565 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-07-06 16:18:50 +02:00
Andrey Kashlak 8e6633f70f
Translated using Weblate (Russian)
Currently translated at 75.9% (476 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-07-06 15:04:55 +02:00
Andrey Kashlak eaca985d44
Translated using Weblate (Russian)
Currently translated at 73.6% (462 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-07-06 14:56:15 +02:00
Andrey Kashlak 1d5def2d8e
Translated using Weblate (Russian)
Currently translated at 71.6% (449 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-07-06 14:40:26 +02:00
Andrey Kashlak 7d5077cd8f
Translated using Weblate (Russian)
Currently translated at 53.5% (336 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-07-06 13:16:31 +02:00
Jesse James Isler b24365640f
Translated using Weblate (German)
Currently translated at 51.1% (321 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-07-06 13:16:30 +02:00
J. Lavoie 024af54cd1
Translated using Weblate (German)
Currently translated at 48.0% (301 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-07-03 02:38:09 +02:00
Andrey Kashlak 5cf1fb831a
Translated using Weblate (Russian)
Currently translated at 51.3% (322 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-07-02 00:33:16 +02:00
J. Lavoie eb6ade1fac
Translated using Weblate (German)
Currently translated at 47.6% (299 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-07-02 00:33:15 +02:00
J. Lavoie 49fa8dd5ff
Translated using Weblate (French)
Currently translated at 100.0% (627 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-06-30 23:17:04 +02:00
Allan Nordhøy 004484fbd7
Translated using Weblate (French)
Currently translated at 73.8% (463 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-06-30 00:24:37 +02:00
Luis 86e9cfdf4b
Translated using Weblate (Spanish)
Currently translated at 7.8% (49 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/es/
2021-06-30 00:24:36 +02:00
Allan Nordhøy 32dd9bf138
Translated using Weblate (German)
Currently translated at 47.2% (296 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-06-30 00:24:36 +02:00
Allan Nordhøy 7476949852
Translated using Weblate (Norwegian Bokmål)
Currently translated at 77.9% (489 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/nb_NO/
2021-06-30 00:24:36 +02:00
Allan Nordhøy f7e24f9df3
Translated using Weblate (English)
Currently translated at 98.5% (618 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/en/
2021-06-30 00:24:36 +02:00
kolaente 82b756cd99
Add pt translation file 2021-06-30 00:24:21 +02:00
Allan Nordhøy 6ade8c6607
Translated using Weblate (Norwegian Bokmål)
Currently translated at 41.9% (263 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/nb_NO/
2021-06-28 20:37:30 +02:00
Allan Nordhøy da71cf7220
Translated using Weblate (Russian)
Currently translated at 56.4% (354 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-28 20:37:17 +02:00
Allan Nordhøy 4a7d0d5b7b
Translated using Weblate (French)
Currently translated at 86.6% (543 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-06-28 20:37:17 +02:00
Allan Nordhøy 76f67f60bc
Translated using Weblate (German)
Currently translated at 52.6% (330 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-06-28 20:37:17 +02:00
Allan Nordhøy 812d1ba560
Translated using Weblate (English)
Currently translated at 100.0% (627 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/en/
2021-06-28 20:37:16 +02:00
Allan Nordhøy aef4792be5
Added translation using Weblate (Norwegian Bokmål) 2021-06-28 00:00:38 +02:00
kolaente e2959f210d
Merge branch 'main' into translations 2021-06-27 13:53:16 +02:00
kolaente 33ff902c6c
Remove empty translation strings 2021-06-27 13:49:44 +02:00
kolaente fca4b93002
Fix broken translation key 2021-06-27 13:41:15 +02:00
Luis dc41288ec1
Translated using Weblate (Spanish)
Currently translated at 6.5% (41 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/es/
2021-06-27 13:40:40 +02:00
Anonymous 2fd47b585d
Translated using Weblate (French)
Currently translated at 97.2% (610 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-06-26 00:46:41 +02:00
Anonymous 44a4e08d0d
Translated using Weblate (Russian)
Currently translated at 97.2% (610 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-26 00:46:33 +02:00
Anonymous db31574858
Translated using Weblate (Romanian)
Currently translated at 97.2% (610 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ro/
2021-06-26 00:46:23 +02:00
Anonymous 345f02b66a
Translated using Weblate (Spanish)
Currently translated at 97.2% (610 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/es/
2021-06-26 00:45:56 +02:00
Anonymous 53a4e463f2
Translated using Weblate (German)
Currently translated at 97.2% (610 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-06-26 00:45:26 +02:00
Konrad d3586a3d5c
Translated using Weblate (English)
Currently translated at 100.0% (627 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/en/
2021-06-26 00:38:30 +02:00
kolaente 3aa8488dc4
Fix labels translation string in filters 2021-06-26 00:31:27 +02:00
Swann Fournial b0827e2ba8
Translated using Weblate (French)
Currently translated at 96.8% (607 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-06-25 23:46:45 +02:00
Swann Fournial 4123d739d9
Translated using Weblate (German)
Currently translated at 60.1% (377 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-06-25 23:46:45 +02:00
Andrey Kashlak d55fdbf223
Translated using Weblate (Russian)
Currently translated at 64.4% (404 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-25 18:30:29 +02:00
Andrey Kashlak 2b8884c39a
Translated using Weblate (Russian)
Currently translated at 64.1% (402 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-25 18:25:28 +02:00
Andrey Kashlak b93d853022
Translated using Weblate (Russian)
Currently translated at 63.1% (396 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-25 18:19:51 +02:00
Andrey Kashlak 3db06bc81b
Translated using Weblate (Russian)
Currently translated at 59.3% (372 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-25 18:09:18 +02:00
Andrey Kashlak 3416c2598e
Translated using Weblate (Russian)
Currently translated at 57.2% (359 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-25 18:01:18 +02:00
Andrey Kashlak 2d754f0aac
Translated using Weblate (Russian)
Currently translated at 46.7% (293 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-25 17:42:37 +02:00
Swann Fournial 01669831e5
Translated using Weblate (French)
Currently translated at 96.8% (607 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-06-25 17:08:53 +02:00
Andrey Kashlak b25cea2180
Translated using Weblate (Russian)
Currently translated at 44.4% (279 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-25 17:08:53 +02:00
Swann Fournial be86427374
Translated using Weblate (German)
Currently translated at 35.8% (225 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/de/
2021-06-25 17:08:53 +02:00
Konrad 4dbec1acab
Translated using Weblate (English)
Currently translated at 99.8% (626 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/en/
2021-06-25 17:08:53 +02:00
kolaente e096de57d3
Merge branch 'main' into translations 2021-06-25 14:09:54 +02:00
Swann Fournial 4ba6261549
Translated using Weblate (French)
Currently translated at 96.0% (602 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-06-25 07:43:39 +02:00
Nathan a707931c55
Translated using Weblate (French)
Currently translated at 0.1% (1 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/fr/
2021-06-24 13:31:37 +02:00
Andrey Kashlak 44bdbd2fdb
Translated using Weblate (Russian)
Currently translated at 0.1% (1 of 627 strings)

Translation: Vikunja/Frontend
Translate-URL: https://hosted.weblate.org/projects/vikunja/frontend/ru/
2021-06-24 13:31:37 +02:00
225 changed files with 16005 additions and 15347 deletions

View File

@ -67,7 +67,7 @@ steps:
depends_on:
- dependencies
- name: lint
- name: build
image: node:16
pull: true
environment:
@ -75,28 +75,7 @@ steps:
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- yarn run lint
depends_on:
- dependencies
# Building in dev mode to avoid the service worker for testing
- name: build-dev
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- yarn build:dev
depends_on:
- dependencies
- name: build-prod
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- yarn build --dest dist-prod
- yarn run build
depends_on:
- dependencies
@ -109,21 +88,20 @@ steps:
- dependencies
- name: test-frontend
image: cypress/browsers:node14.17.0-chrome91-ff89
image: cypress/browsers:node12.18.3-chrome87-ff82
pull: true
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 20000
commands:
- sed -i 's/localhost/api/g' dist-dev/index.html
- yarn serve:dist-dev & npx wait-on http://localhost:5000
- sed -i 's/localhost/api/g' public/index.html
- yarn serve & npx wait-on http://localhost:8080
- yarn test:frontend --browser chrome
depends_on:
- dependencies
- build-dev
- name: upload-test-results
image: plugins/s3:1
@ -310,7 +288,7 @@ trigger:
- push
depends_on:
- release-latest
- release-latest
steps:
- name: trigger
@ -341,7 +319,7 @@ trigger:
- "refs/tags/**"
steps:
- name: docker-unstable
- name: docker-latest
image: plugins/docker:linux-arm
pull: true
settings:
@ -350,7 +328,7 @@ steps:
password:
from_secret: docker_password
repo: vikunja/frontend
tags: unstable-linux-arm
tags: latest-linux-arm
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=unstable
@ -380,7 +358,7 @@ steps:
depends_on:
- clone
- name: docker-unstable-arm64
- name: docker-latest-arm64
image: plugins/docker:linux-arm64
pull: true
settings:
@ -389,7 +367,7 @@ steps:
password:
from_secret: docker_password
repo: vikunja/frontend
tags: unstable-linux-arm64
tags: latest-linux-arm64
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=unstable
@ -438,7 +416,7 @@ trigger:
- "refs/tags/**"
steps:
- name: docker-unstable
- name: docker-latest
image: plugins/docker:linux-amd64
pull: true
settings:
@ -447,7 +425,7 @@ steps:
password:
from_secret: docker_password
repo: vikunja/frontend
tags: unstable-linux-amd64
tags: latest-linux-amd64
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=unstable
@ -488,12 +466,12 @@ depends_on:
- docker-arm-release
steps:
- name: manifest-unstable
- name: manifest-latest
pull: always
image: plugins/manifest
settings:
tags: unstable
spec: docker-manifest-unstable.tmpl
tags: latest
spec: docker-manifest-latest.tmpl
password:
from_secret: docker_password
username:
@ -516,23 +494,6 @@ steps:
when:
ref:
- "refs/tags/**"
- name: manifest-release-latest
pull: always
image: plugins/manifest
depends_on:
- clone
settings:
tags: latest
ignore_missing: true
spec: docker-manifest.tmpl
password:
from_secret: docker_password
username:
from_secret: docker_username
when:
ref:
- "refs/tags/**"
---
kind: pipeline
@ -569,8 +530,7 @@ steps:
- failure
---
kind: pipeline
type: docker
name: update-translations
name: ping-weblate
depends_on:
- build
@ -582,51 +542,20 @@ trigger:
- push
steps:
- name: download
pull: always
image: jonasfranz/crowdin
settings:
download: true
export_dir: src/i18n/lang/
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key
- name: move-files
pull: always
image: bash
depends_on:
- download
commands:
- mv src/i18n/lang/*/*.json src/i18n/lang
- name: push
pull: always
- name: update-translation-base
image: appleboy/drone-git-push
depends_on:
- move-files
failure: ignore
settings:
author_email: "frederik@vikunja.io"
author_name: Frederick [Bot]
branch: main
commit: true
commit_message: "[skip ci] Updated translations via Crowdin"
remote: "ssh://git@kolaente.dev:9022/vikunja/frontend.git"
branch: translations
remote: ssh://git@kolaente.dev:9022/vikunja/frontend.git
ssh_key:
from_secret: translation_git_push_ssh_key
- name: upload
pull: always
image: jonasfranz/crowdin
from_secret: translations_branch_update_ssh_key
- name: notify-weblate
image: curlimages/curl
depends_on:
- clone
settings:
files:
en.json: src/i18n/lang/en.json
ignore_branch: true
project_identifier: vikunja
- update-translation-base
environment:
CROWDIN_KEY:
from_secret: crowdin_key
WEBLATE_TOKEN:
from_secret: weblate_token
commands:
- ./ping-weblate.sh

View File

@ -1,22 +0,0 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
[*.vue]
indent_style = tab
[*.{yaml,yml}]
indent_style = space
indent_size = 2
[*.json]
indent_style = space
indent_size = 2

4
.gitignore vendored
View File

@ -1,6 +1,6 @@
.DS_Store
node_modules
/dist*
/dist
*.zip
# local env files
@ -11,7 +11,6 @@ node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
stats.html
# Editor directories and files
.idea
@ -21,7 +20,6 @@ stats.html
*.njsproj
*.sln
*.sw*
!rollup.sw.js
# Test files
cypress/screenshots

View File

@ -2,347 +2,13 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
All releases can be found on https://code.vikunja.io/frontend/releases.
The releases aim at the api versions which is why there are missing versions.
## [0.18.1] - 2021-09-08
### Added
* feat: make it possible to fake online state via dev env (#720)
### Fixed
* fix: call to /null from background image (#714)
* Fix data export download progress
* fix: kanban-card mutatation violation (#712)
* Fix missing translation when creating a new task on the kanban board
* Fix rearranging tasks in a kanban bucket when its limit was reached
* Fix sort order for table view
* Fix task attributes overridden when saving the task title with enter
* Fix translation badge
### Dependency Updates
* Update dependency @4tw/cypress-drag-drop to v2 (#711)
* Update dependency axios to v0.21.4 (#705)
* Update dependency jest to v27.1.1 (#716)
* Update dependency vite-plugin-vue2 to v1.8.2 (#707)
* Update dependency vite to v2.5.4 (#708)
* Update dependency vite to v2.5.5 (#709)
* Update typescript-eslint monorepo to v4.31.0 (#706)
## [0.18.0] - 2021-09-05
### Added
* Add a button to copy an attachment url from the attachment overview
* Add collapsing kanban buckets
* Add confirm with enter when setting a new password
* Add default list setting & creating tasks from home (#520)
* Add depends_on for push step
* Add depends_on for upload step
* Add drag delay on mobile
* Add express for serve:dev
* Add filters for quick action bar
* Add frontend tests for list history
* Add making tasks favorite from the task detail view
* Add missing position property to list and bucket models
* Add more debug logs for gantt charts
* Add more global state tests (#521)
* Add proofread languages to available languages
* Add quick action bar shortcut to shortcut overview
* Add setting for the first day of the week
* Add showing version info in GUI
* Add syncing translations to crowdin
* Add timeout to fix race condition when authenticating as a link share and renewing the token simultaneously
* Add translations (#562)
* Add typescript support for helper functions (#598)
* Add vite (#416)
* Allow failure of the weblate update step
* Always set the kanban board to full width for share links
* Another day, another js date edge-case
* Automatically update approved translations from crowdin
* Break long list titles in list overview
* Preload labels and use locally stored in vuex
* PWA improvments (#622)
* Quick Actions & global search (#528)
* Quick add magic for tasks (#570)
* Reorder tasks, lists and kanban buckets (#620)
* Show last visited list on home page
* Show recently visited lists in quick actions
* Show salutation based on the time of day
* Sort labels alphabetically on tasks
* Switch the :latest docker image tag to contain the latest release instead of the latest unstable
### Changed
* Change building latest docker image
* Change desktop downstream trigger plugin with our own debug build
* Change menu hamburger icon
* Change quick add magic characters to be more familiar with the todoist ones
* Change the docker builder image to a working one on arm
* chore: discard old font file formats (#673)
* chore: only import common languages (#671)
* Cleanup broken sw functions
* Cleanup drone pipeline
* Cleanup old vue cli config
* Configure tests retries
* Decrease page padding on task detail page
* Directly redirect to the openid auth provider if that's the only auth method
* Don't allow dragging a list when the user does not have the rights
* Don't load already loaded task attachments again when saving an edited task description
* Don't prefetch all i18n files
* Don't show archived lists/namespaces in quick actions
* feat: provide global variables in all components (#669)
* Hide favorite list edit menu
* Hide keyboard shortcuts indicator on mobile
* Improve chunk size
* Improve some translations (#581)
* Improve tests
* Indicate done tasks in quick actions
* Load list background in list card
* Make editor edit button at the bottom the default and make sure the done button stands out more
* Make saving a text edit a button
* Make sure highlight.js is always lazy-loaded
* Make sure the task popup view takes up all the space it can on mobile
* Make tests less flaky
* Make the logo smaller on link shared lists
* Make the progress bar color lighter
* Move creation of new items to the bottom of the multiselect list
* Move general settings to the top
* Move translated files after downloading them
* Move weblate ping to shell script
* Only add a drag delay if on mobile instead of setting it to 0
* Only build a bundle for modern browsers
* Refactor success and error messages
* Refactor success and error notifications to prevent html in them
* Remove logout button for link shares
* Run frontend-tests with dist in ci (#605)
* Save auth tokens from link shares only in memory, don't persist them to localStorage
* Search namespaces locally only when duplicating a list
* Show errors from openid provider
* Show labels alphabetically sorted in the overview
* Small cleanups & code improvements
* TOTP UX improvements & translation fixes
### Fixed
* Fix changing the repeat mode of a task when no value is entered yet
* Fix comment on different task after clicking on a task notification
* Fix CTA spacings
* Fix date parsing parsing words with weekdays in them (#607)
* fix(deps): update dependency marked to v3.0.1 (#677)
* fix(deps): update dependency marked to v3.0.2 (#682)
* Fix error property already defined as a function
* Fix flickering pre-loaded search results when focusing the search input
* Fix Gantt layout overflowsing on mobile
* Fix gantt months being wrong
* Fix git push remote to update crowdin translations
* Fix global mutation of has tasks state
* Fix header layout for long list titles
* Fix highlight.js in editor
* Fix home page tests
* Fix keyboard shortcuts not working on the task detail page
* Fix label changes appearing to be saved immediately when editing them
* Fix labels list in saved filter spacing
* Fix lint
* Fix list archived notification mobile layout
* Fix list settings not being available when list backgrounds are disabled
* Fix lists showing up multiple times in history
* Fix llama background url
* Fix loading a list when it was already partially saved in vuex
* Fix loading & disabled state on inputs when creating a new task
* Fix loading labels when editing a saved filter
* Fix menu styles
* Fix missing background for tasks on a shared list with a background
* Fix multiselect search padding
* Fix new lists created with quick actions not showing up in the menu
* fix: non unique ids (#672)
* Fix not reloading tasks of a saved filter after editing it
* Fix not updating list name in store when changing it
* Fix other values getting pushed away when creating a new one through multiselect
* Fix padding for kanban cards
* Fix parsing dates on the last day of the month
* Fix populating task details ater updating the description
* Fix quick actions not opening
* Fix quick actions not working when nonexisting lists where left over in history
* Fix redirecting to /login for some routes
* Fix removing a namespace from state after it was deleted
* Fix resetting date filters from upcoming after viewing a task detail page (popup)
* Fix sass division
* Fix saving showing archived setting
* Fix selecting a single value from multiselect
* Fix sending openid scopes when authenticating
* Fix sending the user back to the list view they came from when opening a task in detail view
* Fix setting a task as favorite button
* Fix setting delete button for newly created task comments
* Fix setting filters for reminders
* Fix setting secret for updating translations
* Fix setting task favorite status in test fixtures
* Fix showing an editor save button in cases where it wasn't required
* Fix showing edit buttons when the user does not have the rights to use them
* Fix showing import tasks cta when tasks are loading
* Fix some translation strings
* Fix sorting labels
* Fix spacing for task detail view in lists with a background
* Fix table headers wrapping in table view
* Fix table text alignment in task detail page
* Fix table view scrolling on mobile
* Fix test for saving a task description
* Fix tests failing on thursdays
* Fix token in storage not getting renewed
* Fix translating dates
* Fix usage of / in sass
* Fix user name and avatar alignment in navbar
* Fix users not removed from the list in settings when unshared
* Fix user test fixtures
* fix: vuex mutation violation from draggable (#674)
### Dependency Updates
* chore(deps): update dependency @4tw/cypress-drag-drop to v1.8.1 (#693)
* chore(deps): update dependency autoprefixer to v10.3.3 (#684)
* chore(deps): update dependency autoprefixer to v10.3.4 (#697)
* chore(deps): update dependency axios to v0.21.2 (#698)
* chore(deps): update dependency axios to v0.21.3 (#700)
* chore(deps): update dependency cypress to v8.3.1 (#689)
* chore(deps): update dependency esbuild to v0.12.23 (#683)
* chore(deps): update dependency esbuild to v0.12.24 (#688)
* chore(deps): update dependency esbuild to v0.12.25 (#696)
* chore(deps): update dependency eslint-plugin-vue to v7.17.0 (#686)
* chore(deps): update dependency jest to v27.1.0 (#687)
* chore(deps): update dependency sass to v1.38.1 (#679)
* chore(deps): update dependency sass to v1.38.2 (#690)
* chore(deps): update dependency sass to v1.39.0 (#695)
* chore(deps): update dependency typescript to v4.4.2 (#685)
* chore(deps): update dependency vite-plugin-pwa to v0.11.2 (#681)
* chore(deps): update dependency vite to v2.5.1 (#680)
* chore(deps): update dependency vite to v2.5.2 (#692)
* chore(deps): update dependency vite to v2.5.3 (#694)
* chore(deps): update typescript-eslint monorepo to v4.29.3 (#676)
* chore(deps): update typescript-eslint monorepo to v4.30.0 (#691)
* Update dependency autoprefixer to v10.3.2 (#670)
* Update dependency browserslist to v4.16.7 (#634)
* Update dependency browserslist to v4.16.8 (#664)
* Update dependency browserslist to v4.17.0 (#701)
* Update dependency bulma to v0.9.3 (#554)
* Update dependency cypress-file-upload to v5.0.8 (#556)
* Update dependency cypress to v7.3.0 (#507)
* Update dependency cypress to v7.4.0 (#517)
* Update dependency cypress to v7.5.0 (#541)
* Update dependency cypress to v7.6.0 (#561)
* Update dependency cypress to v7.7.0 (#577)
* Update dependency cypress to v8.1.0 (#624)
* Update dependency cypress to v8.2.0 (#637)
* Update dependency cypress to v8.3.0 (#660)
* Update dependency cypress to v8 (#601)
* Update dependency date-fns to v2.22.0 (#523)
* Update dependency date-fns to v2.22.1 (#524)
* Update dependency date-fns to v2.23.0 (#604)
* Update dependency dompurify to v2.2.9 (#529)
* Update dependency dompurify to v2.3.0 (#573)
* Update dependency dompurify to v2.3.1 (#655)
* Update dependency esbuild to v0.12.15 (#610)
* Update dependency esbuild to v0.12.16 (#614)
* Update dependency esbuild to v0.12.17 (#623)
* Update dependency esbuild to v0.12.18 (#638)
* Update dependency esbuild to v0.12.19 (#643)
* Update dependency esbuild to v0.12.20 (#654)
* Update dependency esbuild to v0.12.21 (#666)
* Update dependency esbuild to v0.12.22 (#668)
* Update dependency eslint-plugin-vue to v7.10.0 (#525)
* Update dependency eslint-plugin-vue to v7.11.0 (#547)
* Update dependency eslint-plugin-vue to v7.11.1 (#548)
* Update dependency eslint-plugin-vue to v7.12.1 (#565)
* Update dependency eslint-plugin-vue to v7.13.0 (#574)
* Update dependency eslint-plugin-vue to v7.14.0 (#597)
* Update dependency eslint-plugin-vue to v7.15.0 (#625)
* Update dependency eslint-plugin-vue to v7.15.1 (#633)
* Update dependency eslint-plugin-vue to v7.16.0 (#648)
* Update dependency eslint to v7.27.0 (#514)
* Update dependency eslint to v7.28.0 (#539)
* Update dependency eslint to v7.29.0 (#555)
* Update dependency eslint to v7.30.0 (#571)
* Update dependency eslint to v7.31.0 (#596)
* Update dependency eslint to v7.32.0 (#627)
* Update dependency highlight.js to v11.0.1 (#538)
* Update dependency highlight.js to v11.1.0 (#582)
* Update dependency highlight.js to v11.2.0 (#630)
* Update dependency highlight.js to v11 (#527)
* Update dependency jest to v27.0.3 (#526)
* Update dependency jest to v27.0.4 (#535)
* Update dependency jest to v27.0.5 (#558)
* Update dependency jest to v27.0.6 (#569)
* Update dependency jest to v27 (#519)
* Update dependency marked to v2.0.4 (#510)
* Update dependency marked to v2.0.5 (#513)
* Update dependency marked to v2.0.6 (#522)
* Update dependency marked to v2.0.7 (#532)
* Update dependency marked to v2.1.0 (#552)
* Update dependency marked to v2.1.1 (#553)
* Update dependency marked to v2.1.2 (#559)
* Update dependency marked to v2.1.3 (#567)
* Update dependency marked to v3 (#657)
* Update dependency @rollup/plugin-commonjs to v19.0.2 (#617)
* Update dependency sass to v1.33.0 (#512)
* Update dependency sass to v1.34.0 (#515)
* Update dependency sass to v1.34.1 (#534)
* Update dependency sass to v1.35.0 (#550)
* Update dependency sass to v1.35.1 (#551)
* Update dependency sass to v1.35.2 (#579)
* Update dependency sass to v1.36.0 (#606)
* Update dependency sass to v1.37.0 (#628)
* Update dependency sass to v1.37.2 (#632)
* Update dependency sass to v1.37.5 (#635)
* Update dependency sass to v1.38.0 (#661)
* Update dependency ts-jest to v27.0.4 (#602)
* Update dependency ts-jest to v27.0.5 (#662)
* Update dependency @types/jest to v27.0.1 (#653)
* Update dependency @types/jest to v27 (#650)
* Update dependency vite-plugin-pwa to v0.10.0 (#644)
* Update dependency vite-plugin-pwa to v0.11.0 (#667)
* Update dependency vite-plugin-pwa to v0.8.2 (#612)
* Update dependency vite-plugin-pwa to v0.9.3 (#629)
* Update dependency vite-plugin-vue2 to v1.7.3 (#613)
* Update dependency vite-plugin-vue2 to v1.8.0 (#646)
* Update dependency vite-plugin-vue2 to v1.8.1 (#656)
* Update dependency vite to v2.4.3 (#611)
* Update dependency vite to v2.4.4 (#619)
* Update dependency vite to v2.5.0 (#658)
* Update dependency vue-advanced-cropper to v1.6.0 (#516)
* Update dependency vue-advanced-cropper to v1.7.0 (#543)
* Update dependency vue-advanced-cropper to v1.8.0 (#641)
* Update dependency vue-advanced-cropper to v1.8.1 (#642)
* Update dependency vue-advanced-cropper to v1.8.2 (#645)
* Update dependency vue-flatpickr-component to v8.1.7 (#572)
* Update dependency vue-i18n to v8.24.5 (#564)
* Update dependency vue-i18n to v8.25.0 (#595)
* Update dependency vue-router to v3.5.2 (#557)
* Update dependency wait-on to v6 (#568)
* Update dependency workbox-cli to v6.1.5 (#609)
* Update Font Awesome (#636)
* Update Node.js (#549)
* Update Node.js to v16.4.1 (#576)
* Update Node.js to v16.4.2 (#578)
* Update typescript-eslint monorepo to v4.28.4 (#600)
* Update typescript-eslint monorepo to v4.28.5 (#618)
* Update typescript-eslint monorepo to v4.29.0 (#631)
* Update typescript-eslint monorepo to v4.29.1 (#647)
* Update typescript-eslint monorepo to v4.29.2 (#659)
* Update vue monorepo to v2.6.13 (#530)
* Update vue monorepo to v2.6.14 (#540)
* Update workbox monorepo to v6.2.0 (#639)
* Update workbox monorepo to v6.2.2 (#640)
* Update workbox monorepo to v6.2.4 (#649)
* User account deletion (#651)
* User Data Export and import (#699)
## [0.17.0 - 2021-05-14]
### Added
@ -482,8 +148,7 @@ The releases aim at the api versions which is why there are missing versions.
* Make sure all arm64 build steps run in parallel
* Make sure all empty pages have a call to action
* Make sure all popups & dropdowns are animated
* Make sure attachements are only added once to the list after uploading + Make sure the attachment list shows up every
time after adding an attachment
* Make sure attachements are only added once to the list after uploading + Make sure the attachment list shows up every time after adding an attachment
* Make sure no cta's are visible while the page is loading
* Make sure the loading spinner is always visible at the end of the page
* Make the button shadow lighter
@ -1010,7 +675,7 @@ The releases aim at the api versions which is why there are missing versions.
* Hide totp settings if it is disabled server side
* Increase network timeout when building docker image
* Make sure the version includes the tag when building docker images
* # PrideMonth
* #PrideMonth
* Only renew user token on tab focus events
* Redirect the user to login page if the token expired when the tab gets focus again
* Remove title length restrictions
@ -1045,7 +710,7 @@ The releases aim at the api versions which is why there are missing versions.
## [0.13] - 2020-05-12
#### Added
#### Added
* Add docker run script to change api url on startup
* Add github token for renovate (#89)
@ -1390,7 +1055,6 @@ The releases aim at the api versions which is why there are missing versions.
* Use email instead of username when resetting a password
### Fixed
* Fixed trying to verify an email when there was none
* Fixed loading tasks when the user was not authenticated

View File

@ -4,8 +4,8 @@
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.18.1-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
[![Download](https://img.shields.io/badge/download-v0.17.0-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://hosted.weblate.org/widgets/vikunja/-/frontend/svg-badge.svg)](https://hosted.weblate.org/engage/vikunja/)
This is the web frontend for Vikunja, written in Vue.js.
@ -20,25 +20,21 @@ If you find any security-related issues you don't want to disclose publicly, ple
There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled.
## Project setup
```shell
```
yarn install
```
### Compiles and hot-reloads for development
```shell
```
yarn run serve
```
### Compiles and minifies for production
```shell
```
yarn run build
```
### Lints and fixes files
```shell
```
yarn run lint
```

View File

@ -1,5 +1,5 @@
module.exports = {
presets: [
'@vue/app',
],
'@vue/app'
]
}

View File

@ -1,5 +1,5 @@
{
"baseUrl": "http://localhost:5000",
"baseUrl": "http://localhost:8080",
"env": {
"API_URL": "http://localhost:3456/api/v1",
"TEST_SECRET": "testingS3cr3et"

View File

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

View File

@ -13,7 +13,7 @@ export class UserFactory extends Factory {
id: '{increment}',
username: faker.lorem.word(10) + faker.random.uuid(),
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
status: 0,
is_active: true,
created: formatISO(now),
updated: formatISO(now)
}

View File

@ -72,7 +72,7 @@ describe('Lists', () => {
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
cy.get('#listtext')
.type(`{selectall}${newListName}`)
cy.get('footer.modal-card-foot .button')
.contains('Save')
@ -253,11 +253,11 @@ describe('Lists', () => {
describe('Gantt View', () => {
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.contain', tasks[0].title)
.should('be.empty')
})
it('Shows tasks from the current and next month', () => {
@ -436,23 +436,26 @@ describe('Lists', () => {
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks .dropper div')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
// The following test does not work. It seems like vue-smooth-dnd does not use either mousemove or dragstart
// (not sure why this actually works at all?) and as I'm planning to swap that out for vuedraggable/sortable.js
// anyway, I figured it wouldn't be worth the hassle right now.
// it('Can drag tasks around', () => {
// const tasks = TaskFactory.create(2, {
// list_id: 1,
// bucket_id: 1,
// })
// cy.visit('/lists/1/kanban')
//
// cy.get('.kanban .bucket .tasks .task')
// .contains(tasks[0].title)
// .first()
// .drag('.kanban .bucket:nth-child(2) .tasks .smooth-dnd-container.vertical')
// .trigger('mousedown', {which: 1})
// .trigger('mousemove', {clientX: 500, clientY: 0})
// .trigger('mouseup', {force: true})
// })
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {

View File

@ -169,7 +169,7 @@ describe('Task', () => {
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.type('{selectall}New Description')
cy.get('.task-view .details.content.description .editor a')
.contains('Save')
.contains('Done')
.click()
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')

View File

@ -36,6 +36,7 @@ describe('User Settings', () => {
.contains('Save')
.click()
cy.wait(3000) // Wait for the request to finish
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.navbar .user .username')

View File

@ -1,4 +1,3 @@
import './commands'
import 'cypress-file-upload'
import '@4tw/cypress-drag-drop'

View File

@ -1,17 +1,17 @@
image: vikunja/frontend:unstable
image: vikunja/frontend:latest
manifests:
-
image: vikunja/frontend:unstable-linux-amd64
image: vikunja/frontend:latest-linux-amd64
platform:
architecture: amd64
os: linux
-
image: vikunja/frontend:unstable-linux-arm64
image: vikunja/frontend:latest-linux-arm64
platform:
architecture: arm64
os: linux
-
image: vikunja/frontend:unstable-linux-arm
image: vikunja/frontend:latest-linux-arm
platform:
architecture: arm
os: linux

View File

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Vikunja</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="description" content="Vikunja (/vɪˈkuːnjə/) - The to-do app to organize your life.">
<meta name="theme-color" content="#1973ff"/>
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/images/icons/apple-touch-icon-180x180.png"/>
<link rel="preload" crossorigin="anonymous" href="/fonts/open-sans-v15-latin-700italic.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/open-sans-v15-latin-italic.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/quicksand-v7-latin-300.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/quicksand-v7-latin-500.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/quicksand-v7-latin-700.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/open-sans-v15-latin-regular.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/open-sans-v15-latin-700.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/quicksand-v7-latin-regular.woff2" as="font">
</head>
<body>
<noscript>
<strong>We're sorry but Vikunja doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
//
// This variable points the frontend to the api.
// It has to be the full url, including the last /api/v1 part and port.
// You can change this if your api is not reachable on the same port as the frontend.
window.API_URL = 'http://localhost:3456/api/v1'
//
</script>
</body>
</html>

View File

@ -3,76 +3,62 @@
"version": "0.10.0",
"private": true,
"scripts": {
"serve": "vite",
"serve:dist-dev": "node scripts/serve-dist.js",
"serve:dist": "vite preview",
"build": "vite build && workbox copyLibraries dist/",
"build:dev": "vite build -m development --outDir dist-dev/",
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
"serve": "vue-cli-service serve",
"serve:dist": "node scripts/serve-dist.js",
"build": "vue-cli-service build --modern",
"build:report": "vue-cli-service build --report",
"lint": "vue-cli-service lint --ignore-pattern '*.test.*'",
"cypress:open": "cypress open",
"test:unit": "jest",
"test:frontend": "cypress run"
},
"dependencies": {
"browserslist": "4.17.0",
"browserslist": "4.16.6",
"bulma": "0.9.3",
"camel-case": "4.1.2",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.23.0",
"dompurify": "2.3.1",
"highlight.js": "11.2.0",
"is-touch-device": "1.0.1",
"date-fns": "2.22.1",
"dompurify": "2.3.0",
"highlight.js": "11.0.1",
"lodash": "4.17.21",
"marked": "3.0.3",
"marked": "2.1.3",
"register-service-worker": "1.7.2",
"sass": "1.35.2",
"snake-case": "3.0.4",
"verte": "0.0.12",
"vue": "2.6.14",
"vue-advanced-cropper": "1.8.2",
"vue-advanced-cropper": "1.7.0",
"vue-drag-resize": "1.5.4",
"vue-easymde": "1.4.0",
"vue-i18n": "8.25.0",
"vue-i18n": "8.24.5",
"vue-shortkey": "3.1.7",
"vuedraggable": "2.24.3",
"vuex": "3.6.2",
"workbox-precaching": "6.3.0"
"vue-smooth-dnd": "0.8.1",
"vuex": "3.6.2"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.0.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/fontawesome-svg-core": "1.2.35",
"@fortawesome/free-regular-svg-icons": "5.15.3",
"@fortawesome/free-solid-svg-icons": "5.15.3",
"@fortawesome/vue-fontawesome": "2.0.2",
"@types/jest": "27.0.1",
"@typescript-eslint/eslint-plugin": "4.31.0",
"@typescript-eslint/parser": "4.31.0",
"@vue/babel-preset-app": "4.5.13",
"@vue/eslint-config-typescript": "7.0.0",
"autoprefixer": "10.3.4",
"axios": "0.21.4",
"@vue/cli": "4.5.13",
"@vue/cli-plugin-babel": "4.5.13",
"@vue/cli-plugin-eslint": "4.5.13",
"@vue/cli-plugin-pwa": "4.5.13",
"@vue/cli-service": "4.5.13",
"axios": "0.21.1",
"babel-eslint": "10.1.0",
"cypress": "8.3.1",
"cypress": "7.7.0",
"cypress-file-upload": "5.0.8",
"esbuild": "0.12.26",
"eslint": "7.32.0",
"eslint-plugin-vue": "7.17.0",
"express": "4.17.1",
"eslint": "7.30.0",
"eslint-plugin-vue": "7.13.0",
"faker": "5.5.3",
"jest": "27.1.1",
"rollup-plugin-terser": "7.0.2",
"rollup-plugin-visualizer": "5.5.2",
"sass": "1.39.2",
"ts-jest": "27.0.5",
"typescript": "4.4.2",
"vite": "2.5.6",
"vite-plugin-pwa": "0.11.2",
"vite-plugin-vue2": "1.8.2",
"jest": "27.0.6",
"sass-loader": "10.2.0",
"vue-flatpickr-component": "8.1.7",
"vue-notification": "1.3.20",
"vue-router": "3.5.2",
"vue-template-compiler": "2.6.14",
"wait-on": "6.0.0",
"workbox-cli": "6.3.0"
"wait-on": "6.0.0"
},
"eslintConfig": {
"root": true,
@ -81,32 +67,14 @@
},
"extends": [
"plugin:vue/essential",
"eslint:recommended",
"@vue/typescript"
"eslint:recommended"
],
"rules": {
"vue/html-quotes": [
"error",
"double"
],
"quotes": [
"error",
"single"
],
"comma-dangle": [
"error",
"always-multiline"
],
"semi": [
"error",
"never"
]
},
"rules": {},
"parserOptions": {
"parser": "@typescript-eslint/parser"
"parser": "babel-eslint"
},
"ignorePatterns": [
"*.test.*",
"*.test.js",
"cypress/*"
]
},
@ -118,27 +86,13 @@
"browserslist": [
"> 1%",
"last 2 versions",
"not ie > 0",
"not dead",
"Firefox ESR"
"not ie < 11"
],
"license": "AGPL-3.0-or-later",
"jest": {
"testPathIgnorePatterns": [
"cypress"
],
"testEnvironment": "jsdom",
"preset": "ts-jest",
"roots": [
"<rootDir>/src"
],
"transform": {
"^.+\\.(js|tsx?)$": "ts-jest"
},
"moduleFileExtensions": [
"ts",
"js",
"json"
]
},
"license": "AGPL-3.0-or-later"
"testEnvironment": "jsdom"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -16,6 +16,9 @@
height="1066.6667"
viewBox="0 0 1066.6667 1066.6667"
sodipodi:docname="llama-nightscape.svg"
inkscape:export-filename="/home/konrad/www/vikunja/frontend/public/images/llama-nightscape.png"
inkscape:export-xdpi="172.8"
inkscape:export-ydpi="172.8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

36
public/index.html Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Vikunja</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="description" content="Vikunja (/vɪˈkuːnjə/) - The to-do app to organize your life.">
<meta name="hash" content="<%= webpack.hash %>"/>
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/open-sans-v15-latin-700italic.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/open-sans-v15-latin-italic.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/quicksand-v7-latin-300.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/quicksand-v7-latin-500.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/quicksand-v7-latin-700.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/open-sans-v15-latin-regular.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/open-sans-v15-latin-700.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/quicksand-v7-latin-regular.woff2" as="font">
</head>
<body>
<noscript>
<strong>We're sorry but Vikunja doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script>
//
// This variable points the frontend to the api.
// It has to be the full url, including the last /api/v1 part and port.
// You can change this if your api is not reachable on the same port as the frontend.
window.API_URL = 'http://localhost:3456/api/v1'
//
</script>
</body>
</html>

View File

@ -2,8 +2,8 @@ const path = require('path')
const express = require('express')
const app = express()
const p = path.join(__dirname, '..', 'dist-dev')
const port = 5000
const p = path.join(__dirname, '..', 'dist')
const port = 8080
app.use(express.static(p))
// Handle urls set by the frontend

View File

@ -1,5 +1,5 @@
<template>
<div :class="{'is-touch': isTouch}">
<div>
<div :class="{'is-hidden': !online}">
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
<div class="offline" style="height: 0;width: 0;"></div>
@ -23,18 +23,18 @@
</template>
<script>
import {mapState, mapGetters} from 'vuex'
import isTouchDevice from 'is-touch-device'
import {mapState} from 'vuex'
import authTypes from './models/authTypes'
import Notification from './components/misc/notification'
import {KEYBOARD_SHORTCUTS_ACTIVE, ONLINE} from './store/mutation-types'
import KeyboardShortcuts from './components/misc/keyboard-shortcuts'
import TopNavigation from './components/home/topNavigation'
import ContentAuth from './components/home/contentAuth'
import ContentLinkShare from './components/home/contentLinkShare'
import ContentNoAuth from './components/home/contentNoAuth'
import {setLanguage} from './i18n/setup'
import AccountDeleteService from '@/services/accountDelete'
import TopNavigation from '@/components/home/topNavigation'
import ContentAuth from '@/components/home/contentAuth'
import ContentLinkShare from '@/components/home/contentLinkShare'
import ContentNoAuth from '@/components/home/contentNoAuth'
import {setLanguage} from '@/i18n/setup'
export default {
name: 'app',
@ -50,13 +50,9 @@ export default {
this.setupOnlineStatus()
this.setupPasswortResetRedirect()
this.setupEmailVerificationRedirect()
this.setupAccountDeletionVerification()
},
beforeCreate() {
this.$store.dispatch('config/update')
.then(() => {
this.$store.dispatch('auth/checkAuth')
})
this.$store.dispatch('auth/checkAuth')
setLanguage()
@ -67,19 +63,12 @@ export default {
this.$router.push({name: 'home'})
}
},
computed: {
isTouch() {
return isTouchDevice()
},
...mapState({
online: ONLINE,
keyboardShortcutsActive: KEYBOARD_SHORTCUTS_ACTIVE,
}),
...mapGetters('auth', [
'authUser',
'authLinkShare',
]),
},
computed: mapState({
authUser: state => state.auth.authenticated && (state.auth.info && state.auth.info.type === authTypes.USER),
authLinkShare: state => state.auth.authenticated && (state.auth.info && state.auth.info.type === authTypes.LINK_SHARE),
online: ONLINE,
keyboardShortcutsActive: KEYBOARD_SHORTCUTS_ACTIVE,
}),
methods: {
setupOnlineStatus() {
this.$store.commit(ONLINE, navigator.onLine)
@ -100,17 +89,6 @@ export default {
this.$router.push({name: 'user.login'})
}
},
setupAccountDeletionVerification() {
if (typeof this.$route.query.accountDeletionConfirm !== 'undefined') {
const accountDeletionService = new AccountDeleteService()
accountDeletionService.confirm(this.$route.query.accountDeletionConfirm)
.then(() => {
this.success({message: this.$t('user.deletion.confirmSuccess')})
this.$store.dispatch('auth/refreshUserInfo')
})
.catch(e => this.error(e))
}
},
},
}
</script>

View File

@ -0,0 +1,3 @@
{
"SW_UPDATED": "swUpdated"
}

112
src/ServiceWorker/sw.js Normal file
View File

@ -0,0 +1,112 @@
/* eslint-disable no-console */
/* eslint-disable no-undef */
// Cache assets
workbox.routing.registerRoute(
// This regexp matches all files in precache-manifest
new RegExp('.+\\.(css|json|js|svg|woff2|png|html|txt|wav)$'),
new workbox.strategies.StaleWhileRevalidate(),
)
// Always send api reqeusts through the network
workbox.routing.registerRoute(
new RegExp('api\\/v1\\/.*$'),
new workbox.strategies.NetworkOnly(),
)
// This code listens for the user's confirmation to update the app.
self.addEventListener('message', (e) => {
if (!e.data) {
return
}
switch (e.data) {
case 'skipWaiting':
self.skipWaiting()
break
default:
// NOOP
break
}
})
const getBearerToken = async () => {
// we can't get a client that sent the current request, therefore we need
// to ask any controlled page for auth token
const allClients = await self.clients.matchAll()
const client = allClients.filter(client => client.type === 'window')[0]
// if there is no page in scope, we can't get any token
// and we indicate it with null value
if (!client) {
return null
}
// to communicate with a page we will use MessageChannels
// they expose pipe-like interface, where a receiver of
// a message uses one end of a port for messaging and
// we use the other end for listening
const channel = new MessageChannel()
client.postMessage({
'action': 'getBearerToken',
}, [channel.port1])
// ports support only onmessage callback which
// is cumbersome to use, so we wrap it with Promise
return new Promise((resolve, reject) => {
channel.port2.onmessage = event => {
if (event.data.error) {
console.error('Port error', event.error)
reject(event.data.error)
}
resolve(event.data.authToken)
}
})
}
// Notification action
self.addEventListener('notificationclick', function (event) {
const taskId = event.notification.data.taskId
event.notification.close()
switch (event.action) {
case 'mark-as-done':
// FIXME: Ugly as hell, but no other way of doing this, since we can't use modules
// in service workers for now.
fetch('/config.json')
.then(r => r.json())
.then(config => {
getBearerToken()
.then(token => {
fetch(`${config.VIKUNJA_API_BASE_URL}tasks/${taskId}`, {
method: 'post',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({id: taskId, done: true}),
})
.then(r => r.json())
.then(r => {
console.debug('Task marked as done from notification', r)
})
.catch(e => {
console.debug('Error marking task as done from notification', e)
})
})
})
break
case 'show-task':
clients.openWindow(`/tasks/${taskId}`)
break
}
})
workbox.core.clientsClaim()
// The precaching code provided by Workbox.
self.__precacheManifest = [].concat(self.__precacheManifest || [])
workbox.precaching.precacheAndRoute(self.__precacheManifest, {})

View File

@ -5,7 +5,7 @@
</a>
<div
:class="{'has-background': background}"
:style="{'background-image': background && `url(${background})`}"
:style="{'background-image': `url(${background})`}"
class="app-container"
>
<navigation/>
@ -44,8 +44,8 @@
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
import Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue'
import Navigation from '@/components/home/navigation'
import QuickActions from '@/components/quick-actions/quick-actions'
export default {
name: 'contentAuth',

View File

@ -13,8 +13,16 @@
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
</h1>
<div class="box has-text-left view">
<div class="logout">
<x-button @click="logout()" type="secondary">
<span>{{ $t('user.auth.logout') }}</span>
<span class="icon is-small">
<icon icon="sign-out-alt"/>
</span>
</x-button>
</div>
<router-view/>
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank" rel="noreferrer noopener nofollow">
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank">
{{ $t('misc.poweredBy') }}
</a>
</div>

View File

@ -1,7 +1,7 @@
<template>
<div class="no-auth-wrapper">
<div class="noauth-container">
<img alt="Vikunja" src="/images/logo-full.svg" width="400" height="117"/>
<img alt="Vikunja" src="/images/logo-full.svg"/>
<div class="message is-info" v-if="motd !== ''">
<div class="message-header">
<p>{{ $t('misc.info') }}</p>

View File

@ -2,7 +2,7 @@
<div :class="{'is-active': menuActive}" class="namespace-container">
<div class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo">
<img alt="Vikunja" src="/images/logo-full.svg" width="164" height="48"/>
<img alt="Vikunja" src="/images/logo-full.svg"/>
</router-link>
<ul class="menu-list">
<li>
@ -49,7 +49,7 @@
</div>
<aside class="menu namespaces-lists loader-container" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces">
<template v-for="n in namespaces">
<div :key="n.id" class="namespace-title" :class="{'has-menu': n.id > 0}">
<span
@click="toggleLists(n.id)"
@ -73,47 +73,18 @@
</a>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div>
<div
:key="n.id + 'child'"
class="more-container"
v-if="typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true"
>
<!--
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
-->
<draggable
:value="activeLists[nk]"
@input="(lists) => updateActiveLists(n, lists)"
:group="`namespace-${n.id}-lists`"
@start="() => drag = true"
@end="e => saveListPosition(e, nk)"
v-bind="dragOptions"
handle=".handle"
:disabled="n.id < 0"
:class="{'dragging-disabled': n.id < 0}"
>
<transition-group
type="transition"
:name="!drag ? 'flip-list' : null"
tag="ul"
class="menu-list can-be-hidden"
>
<li
v-for="l in activeLists[nk]"
:key="l.id"
class="loader-container"
:class="{'is-loading': listUpdating[l.id]}"
>
<div :key="n.id + 'child'" class="more-container" v-if="typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true">
<ul class="menu-list can-be-hidden">
<template v-for="l in n.lists">
<!-- This is a bit ugly but vue wouldn't want to let me filter this - probably because the lists
are nested inside of the namespaces makes it a lot harder.-->
<li :key="l.id" v-if="!l.isArchived">
<router-link
class="list-menu-link"
:class="{'router-link-exact-active': currentList.id === l.id}"
:to="{ name: 'list.index', params: { listId: l.id} }"
tag="span"
>
<span class="icon handle">
<icon icon="grip-lines"/>
</span>
<span
:style="{ backgroundColor: l.hexColor }"
class="color-bubble"
@ -133,12 +104,12 @@
<list-settings-dropdown :list="l" v-if="l.id > 0"/>
<span class="list-setting-spacer" v-else></span>
</li>
</transition-group>
</draggable>
</template>
</ul>
</div>
</template>
</aside>
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank" rel="noreferrer noopener nofollow">
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank">
{{ $t('misc.poweredBy') }}
</a>
</div>
@ -147,41 +118,27 @@
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, MENU_ACTIVE, LOADING, LOADING_MODULE} from '@/store/mutation-types'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
import draggable from 'vuedraggable'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
export default {
name: 'navigation',
data() {
return {
listsVisible: {},
drag: false,
dragOptions: {
animation: 100,
ghostClass: 'ghost',
},
listUpdating: {},
}
},
components: {
ListSettingsDropdown,
NamespaceSettingsDropdown,
draggable,
},
computed: {
...mapState({
namespaces: state => state.namespaces.namespaces.filter(n => !n.isArchived),
currentList: CURRENT_LIST,
background: 'background',
menuActive: MENU_ACTIVE,
loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces',
}),
activeLists() {
return this.namespaces.map(({lists}) => lists.filter(item => !item.isArchived))
},
},
computed: mapState({
namespaces: state => state.namespaces.namespaces.filter(n => !n.isArchived),
currentList: CURRENT_LIST,
background: 'background',
menuActive: MENU_ACTIVE,
loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces',
}),
beforeCreate() {
this.$store.dispatch('namespaces/loadNamespaces')
.then(namespaces => {
@ -219,45 +176,6 @@ export default {
toggleLists(namespaceId) {
this.$set(this.listsVisible, namespaceId, !this.listsVisible[namespaceId] ?? false)
},
updateActiveLists(namespace, activeLists) {
// this is a bit hacky: since we do have to filter out the archived items from the list
// for vue draggable updating it is not as simple as replacing it.
// instead we iterate over the non archived items in the old list and replace them with the ones in their new order
const lists = namespace.lists.map((item) => {
if (item.isArchived) {
return item
}
return activeLists.shift()
})
const newNamespace = {
...namespace,
lists,
}
this.$store.commit('namespaces/setNamespaceById', newNamespace)
},
saveListPosition(e, namespaceIndex) {
const listsActive = this.activeLists[namespaceIndex]
const list = listsActive[e.newIndex]
const listBefore = listsActive[e.newIndex - 1] ?? null
const listAfter = listsActive[e.newIndex + 1] ?? null
this.$set(this.listUpdating, list.id, true)
const position = calculateItemPosition(listBefore !== null ? listBefore.position : null, listAfter !== null ? listAfter.position : null)
// create a copy of the list in order to not violate vuex mutations
this.$store.dispatch('lists/updateList', {
...list,
position,
})
.catch(e => {
this.error(e)
})
.finally(() => {
this.$set(this.listUpdating, list.id, false)
})
},
},
}
</script>

View File

@ -7,8 +7,8 @@
>
<div class="navbar-brand">
<router-link :to="{name: 'home'}" class="navbar-item logo">
<img width="164" height="48" alt="Vikunja" src="/images/logo-full-pride.svg" v-if="(new Date()).getMonth() === 5"/>
<img width="164" height="48" alt="Vikunja" src="/images/logo-full.svg" v-else/>
<img alt="Vikunja" src="/images/logo-full-pride.svg" v-if="(new Date()).getMonth() === 5"/>
<img alt="Vikunja" src="/images/logo-full.svg" v-else/>
</router-link>
<a
@click="$store.commit('toggleMenu')"
@ -16,12 +16,14 @@
@shortkey="() => $store.commit('toggleMenu')"
v-shortkey="['ctrl', 'e']"
>
<icon icon="bars"></icon>
</a>
</div>
<a
@click="$store.commit('toggleMenu')"
class="menu-show-button"
>
<icon icon="bars"></icon>
</a>
<div class="list-title" ref="listTitle" :style="{'display': currentList.id ? '': 'none'}">
<template v-if="currentList.id">
@ -47,7 +49,7 @@
</a>
<notifications/>
<div class="user">
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
<img :src="userAvatar" alt="" class="avatar"/>
<dropdown class="is-right" ref="usernameDropdown">
<template v-slot:trigger>
<x-button
@ -67,7 +69,6 @@
:href="imprintUrl"
class="dropdown-item"
target="_blank"
rel="noreferrer noopener nofollow"
v-if="imprintUrl">
{{ $t('navigation.imprint') }}
</a>
@ -75,7 +76,6 @@
:href="privacyPolicyUrl"
class="dropdown-item"
target="_blank"
rel="noreferrer noopener nofollow"
v-if="privacyPolicyUrl">
{{ $t('navigation.privacy') }}
</a>
@ -97,11 +97,11 @@
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import Rights from '@/models/constants/rights.json'
import Update from '@/components/home/update.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import Notifications from '@/components/notifications/notifications.vue'
import Rights from '@/models/rights.json'
import Update from '@/components/home/update'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown'
import Dropdown from '@/components/misc/dropdown'
import Notifications from '@/components/notifications/notifications'
export default {
name: 'topNavigation',

View File

@ -8,6 +8,8 @@
</template>
<script>
import swEvents from '@/ServiceWorker/events.json'
export default {
name: 'update',
data() {
@ -18,7 +20,7 @@ export default {
}
},
created() {
document.addEventListener('swUpdated', this.showRefreshUI, {once: true})
document.addEventListener(swEvents.SW_UPDATED, this.showRefreshUI, {once: true})
if (navigator && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener(

View File

@ -55,7 +55,7 @@ export default {
computed: {
showIconOnly() {
return this.icon !== '' && typeof this.$slots.default === 'undefined'
},
}
},
methods: {
click(e) {

View File

@ -26,6 +26,7 @@
<script>
import verte from 'verte'
import 'verte/dist/verte.css'
export default {
name: 'colorPicker',
@ -90,8 +91,6 @@ export default {
</script>
<style lang="scss">
@import 'verte/dist/verte.css';
.verte.is-empty {
.verte__icon {
opacity: 0;

View File

@ -137,18 +137,18 @@ export default {
},
props: {
value: {
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string'
},
chooseDateLabel: {
type: String,
default() {
return this.$t('input.datepicker.chooseDate')
},
}
},
disabled: {
type: Boolean,
default: false,
},
}
},
mounted() {
this.setDateValue(this.value)

View File

@ -1,5 +1,24 @@
<template>
<div class="editor">
<div :class="{'is-pulled-up': isEditEnabled}" class="editor">
<div class="is-pulled-right mb-4" v-if="hasPreview && isEditEnabled && !hasEditBottom">
<x-button
v-if="!isEditActive"
@click="toggleEdit"
:shadow="false"
type="secondary"
>
<icon icon="pen"/>
</x-button>
<x-button
v-else
@click="toggleEdit"
:shadow="false"
type="secondary"
>
{{ $t('input.editor.done') }}
</x-button>
</div>
<div class="clear"></div>
<vue-easymde
@ -13,34 +32,24 @@
<div class="preview content" v-html="preview" v-if="isPreviewActive && text !== ''">
</div>
<p class="has-text-centered has-text-grey is-italic" v-if="showPreviewText">
<p class="has-text-centered has-text-grey is-italic" v-if="isPreviewActive && text === '' && emptyText !== ''">
{{ emptyText }}
<template v-if="isEditEnabled">
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>.
</template>
</p>
<ul class="actions" v-if="bottomActions.length > 0">
<template v-if="isEditEnabled && !showPreviewText && showSave">
<ul class="actions">
<template v-if="hasEditBottom && isEditEnabled">
<li>
<a v-if="!isEditActive" @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
<a v-else @click="toggleEdit" class="done-edit">{{ $t('misc.save') }}</a>
<a v-else @click="toggleEdit">{{ $t('input.editor.done') }}</a>
</li>
</template>
<li v-for="(action, k) in bottomActions" :key="k">
<a @click="action.action">{{ action.title }}</a>
</li>
</ul>
<template v-else-if="showSave">
<ul v-if="!isEditActive" class="actions">
<li>
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
</li>
</ul>
<x-button v-else @click="toggleEdit" type="secondary" :shadow="false">
{{ $t('misc.save') }}
</x-button>
</template>
</div>
</template>
@ -49,7 +58,6 @@ import VueEasymde from 'vue-easymde'
import EasyMDE from 'easymde'
import marked from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/common'
import AttachmentModel from '../../models/attachment'
import AttachmentService from '../../services/attachment'
@ -88,6 +96,10 @@ export default {
isEditEnabled: {
default: true,
},
hasEditBottom: {
type: Boolean,
default: false,
},
bottomActions: {
default: () => [],
},
@ -95,15 +107,6 @@ export default {
type: String,
default: () => '',
},
showSave: {
type: Boolean,
default: false,
},
},
computed: {
showPreviewText() {
return this.isPreviewActive && this.text === '' && this.emptyText !== ''
},
},
data() {
return {
@ -114,7 +117,6 @@ export default {
preview: '',
attachmentService: null,
loadedAttachments: {},
config: {
autoDownloadFontAwesome: false,
@ -282,7 +284,7 @@ export default {
// that in the end, only one change event is triggered to the outside per change.
handleInput(val) {
// Don't bubble if the text is up to date
if (val === this.text) {
if(val === this.text) {
return
}
@ -363,16 +365,17 @@ export default {
link: (href, title, text) => {
const isLocal = href.startsWith(`${location.protocol}//${location.hostname}`)
const html = linkRenderer.call(renderer, href, title, text)
return isLocal ? html : html.replace(/^<a /, '<a target="_blank" rel="noreferrer noopener nofollow" ')
return isLocal ? html : html.replace(/^<a /, `<a target="_blank" rel="noreferrer noopener nofollow" `)
},
},
highlight: function (code, language) {
const hljs = require('highlight.js')
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext'
return hljs.highlight(code, {language: validLanguage}).value
},
})
this.preview = DOMPurify.sanitize(marked(this.text), {ADD_ATTR: ['target']})
this.preview = DOMPurify.sanitize(marked(this.text), { ADD_ATTR: ['target'] })
// Since the render function is synchronous, we can't do async http requests in it.
// Therefore, we can't resolve the blob url at (markdown) compile time.
@ -389,13 +392,6 @@ export default {
const parts = img.dataset.src.substr(window.API_URL.length + 1).split('/')
const taskId = parseInt(parts[1])
const attachmentId = parseInt(parts[3])
const cacheKey = `${taskId}-${attachmentId}`
if (typeof this.loadedAttachments[cacheKey] !== 'undefined') {
img.src = this.loadedAttachments[cacheKey]
continue
}
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
if (this.attachmentService === null) {
@ -405,7 +401,6 @@ export default {
this.attachmentService.getBlobUrl(attachment)
.then(url => {
img.src = url
this.loadedAttachments[cacheKey] = url
})
}
}
@ -457,18 +452,15 @@ export default {
<style lang="scss">
@import '../../../node_modules/highlight.js/scss/base16/equilibrium-gray-light';
@import '../../../node_modules/easymde/dist/easymde.min.css';
@import '../../styles/theme/variables/all';
.editor {
.clear {
clear: both;
}
.preview.content {
margin-bottom: .5rem;
ul li input[type="checkbox"] {
margin-right: .5rem;
}
.preview.content ul li input[type="checkbox"] {
margin-right: .5rem;
}
}
@ -547,10 +539,6 @@ ul.actions {
&, a {
color: $grey-500;
&.done-edit {
color: $primary;
}
}
a:hover {

View File

@ -4,7 +4,7 @@
:checked="checked"
:disabled="disabled"
:id="checkBoxId"
@change="(event) => updateData(event.target.checked)"
@change="updateData"
style="display: none;"
type="checkbox"/>
<label :for="checkBoxId" class="check">
@ -51,10 +51,10 @@ export default {
this.checkBoxId = 'fancycheckbox' + Math.random()
},
methods: {
updateData(checked) {
this.checked = checked
this.$emit('input', checked)
this.$emit('change', checked)
updateData(e) {
this.checked = e.target.checked
this.$emit('input', this.checked)
this.$emit('change', e.target.checked)
},
},
}

View File

@ -108,21 +108,21 @@ export default {
type: Boolean,
default() {
return false
},
}
},
// The placeholder of the search input
placeholder: {
type: String,
default() {
return ''
},
}
},
// The search results where the @search listener needs to put the results into
searchResults: {
type: Array,
default() {
return []
},
}
},
// The name of the property of the searched object to show the user.
// If empty the component will show all raw data of an entry.
@ -130,13 +130,13 @@ export default {
type: String,
default() {
return ''
},
}
},
// The object with the value, updated every time an entry is selected.
value: {
default() {
return null
},
}
},
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
creatable: {
@ -150,14 +150,14 @@ export default {
type: String,
default() {
return this.$t('input.multiselect.createPlaceholder')
},
}
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
return this.$t('input.multiselect.selectPlaceholder')
},
}
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
multiple: {
@ -222,7 +222,7 @@ export default {
})
},
filteredSearchResults() {
if (this.multiple && this.internalValue !== null && Array.isArray(this.internalValue)) {
if (this.multiple && this.internalValue !== null) {
return this.searchResults.filter(item => !this.internalValue.some(e => e === item))
}

View File

@ -75,9 +75,9 @@
<script>
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import TaskSubscription from '@/components/misc/subscription.vue'
import Dropdown from '@/components/misc/dropdown'
import DropdownItem from '@/components/misc/dropdown-item'
import TaskSubscription from '@/components/misc/subscription'
export default {
name: 'list-settings-dropdown',
@ -101,7 +101,7 @@ export default {
},
computed: {
backgroundsEnabled() {
return this.$store.state.config.enabledBackgroundProviders !== null && this.$store.state.config.enabledBackgroundProviders.length > 0
return this.$store.state.config.enabledBackgroundProviders.length > 0
},
listRoutePrefix() {
let name = 'list'

View File

@ -131,8 +131,25 @@
<div class="field">
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<edit-labels v-model="labels" @change="changeLabelFilter"/>
<div class="control">
<multiselect
:placeholder="$t('label.search')"
@search="findLabels"
:search-results="foundLabels"
@select="label => addLabel(label)"
label="title"
:multiple="true"
v-model="labels"
>
<template v-slot:tag="props">
<span
:style="{'background': props.item.hexColor, 'color': props.item.textColor}"
class="tag ml-2 mt-2">
<span>{{ props.item.title }}</span>
<a @click="removeLabel(props.item)" class="delete is-small"></a>
</span>
</template>
</multiselect>
</div>
</div>
@ -181,48 +198,17 @@ import 'flatpickr/dist/flatpickr.css'
import {formatISO} from 'date-fns'
import differenceWith from 'lodash/differenceWith'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import Multiselect from '@/components/input/multiselect.vue'
import PrioritySelect from '@/components/tasks/partials/prioritySelect'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect'
import Multiselect from '@/components/input/multiselect'
import UserService from '@/services/user'
import ListService from '@/services/list'
import NamespaceService from '@/services/namespace'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
// FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = {
sort_by: [],
order_by: [],
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_include_nulls: true,
filter_concat: 'or',
s: '',
}
const DEFAULT_FILTERS = {
done: false,
dueDate: '',
requireAllFilters: false,
priority: 0,
usePriority: false,
startDate: '',
endDate: '',
percentDone: 0,
usePercentDone: false,
reminders: '',
assignees: '',
labels: '',
list_id: '',
namespace: '',
}
export default {
name: 'filters',
components: {
EditLabels,
PrioritySelect,
Fancycheckbox,
flatPickr,
@ -231,8 +217,32 @@ export default {
},
data() {
return {
params: DEFAULT_PARAMS,
filters: DEFAULT_FILTERS,
params: {
sort_by: [],
order_by: [],
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_include_nulls: true,
filter_concat: 'or',
s: '',
},
filters: {
done: false,
dueDate: '',
requireAllFilters: false,
priority: 0,
usePriority: false,
startDate: '',
endDate: '',
percentDone: 0,
usePercentDone: false,
reminders: '',
assignees: '',
labels: '',
list_id: '',
namespace: '',
},
usersService: UserService,
foundusers: [],
@ -309,12 +319,9 @@ export default {
this.prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
this.prepareDate('reminders')
this.prepareRelatedObjectFilter('users', 'assignees')
this.prepareRelatedObjectFilter('labels', 'labels', 'label')
this.prepareRelatedObjectFilter('lists', 'list_id')
this.prepareRelatedObjectFilter('namespace')
this.prepareSingleValue('labels')
const labelIds = (typeof this.filters.labels === 'string' ? this.filters.labels : '').split(',').map(i => parseInt(i))
this.labels = (Object.values(this.$store.state.labels.labels).filter(l => labelIds.includes(l.id)) ?? [])
},
removePropertyFromFilter(propertyName) {
// Because of the way arrays work, we can only ever remove one element at once.
@ -328,11 +335,7 @@ export default {
}
}
},
setDateFilter(filterName, variableName = null) {
if (variableName === null) {
variableName = filterName
}
setDateFilter(filterName, variableName) {
// Only filter if we have a start and end due date
if (this.filters[variableName] !== '') {

View File

@ -1,34 +1,16 @@
<template>
<div class="content">
<h1>{{ $t('migrate.titleService', {name: name}) }}</h1>
<h1>{{ $t('migrate.titleService', { name: name }) }}</h1>
<p>{{ $t('migrate.descriptionDo') }}</p>
<template v-if="isMigrating === false && message === '' && lastMigrationDate === null">
<template v-if="isFileMigrator">
<p>{{ $t('migrate.importUpload', {name: name}) }}</p>
<input
@change="migrate"
class="is-hidden"
ref="uploadInput"
type="file"
/>
<x-button
:loading="migrationService.loading"
:disabled="migrationService.loading"
@click="$refs.uploadInput.click()"
>
{{ $t('migrate.upload') }}
</x-button>
</template>
<template v-else>
<p>{{ $t('migrate.authorize', {name: name}) }}</p>
<x-button
:loading="migrationService.loading"
:disabled="migrationService.loading"
:href="authUrl"
>
{{ $t('migrate.getStarted') }}
</x-button>
</template>
<p>{{ $t('migrate.authorize', {name: name}) }}</p>
<x-button
:loading="migrationService.loading"
:disabled="migrationService.loading"
:href="authUrl"
>
{{ $t('migrate.getStarted') }}
</x-button>
</template>
<div
class="migration-in-progress-container"
@ -36,7 +18,14 @@
<div class="migration-in-progress">
<img :alt="name" :src="`/images/migration/${identifier}.png`"/>
<div class="progress-dots">
<span v-for="i in progressDotsCount" :key="i" />
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<img alt="Vikunja" src="/images/logo.svg">
</div>
@ -44,7 +33,7 @@
</div>
<div v-else-if="lastMigrationDate">
<p>
{{ $t('migrate.alreadyMigrated1', {name: name, date: formatDate(lastMigrationDate)}) }}<br/>
{{ $t('migrate.alreadyMigrated1', { name: name, date: formatDate(lastMigrationDate) }) }}<br/>
{{ $t('migrate.alreadyMigrated2') }}
</p>
<div class="buttons">
@ -64,22 +53,17 @@
</template>
<script>
import AbstractMigrationService from '../../services/migrator/abstractMigration'
import AbstractMigrationFileService from '../../services/migrator/abstractMigrationFile'
const PROGRESS_DOTS_COUNT = 8
import AbstractMigrationService from '../../services/migrator/abstractMigrationService'
export default {
name: 'migration',
data() {
return {
progressDotsCount: PROGRESS_DOTS_COUNT,
authUrl: '',
isMigrating: false,
lastMigrationDate: null,
message: '',
migratorAuthCode: '',
migrationService: null,
}
},
props: {
@ -91,21 +75,11 @@ export default {
type: String,
required: true,
},
isFileMigrator: {
type: Boolean,
default: false,
},
},
created() {
this.message = ''
if (this.isFileMigrator) {
this.migrationService = new AbstractMigrationFileService(this.identifier)
return
}
this.migrationService = new AbstractMigrationService(this.identifier)
this.getAuthUrl()
this.message = ''
if (typeof this.$route.query.code !== 'undefined' || location.hash.startsWith('#token=')) {
if (location.hash.startsWith('#token=')) {
@ -148,11 +122,6 @@ export default {
this.isMigrating = true
this.lastMigrationDate = null
this.message = ''
if (this.isFileMigrator) {
return this.migrateFile()
}
this.migrationService.migrate({code: this.migratorAuthCode})
.then(r => {
this.message = r.message
@ -165,23 +134,6 @@ export default {
this.isMigrating = false
})
},
migrateFile() {
if (this.$refs.uploadInput.files.length === 0) {
return
}
this.migrationService.migrate(this.$refs.uploadInput.files[0])
.then(r => {
this.message = r.message
this.$store.dispatch('namespaces/loadNamespaces')
})
.catch(e => {
this.error(e)
})
.finally(() => {
this.isMigrating = false
})
},
},
}
</script>

View File

@ -24,7 +24,7 @@
</div>
<div class="api-url-info" v-else>
<i18n path="apiConfig.signInOn">
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
<span class="url" v-tooltip="apiUrl"> {{ apiDomain() }} </span>
</i18n>
<br />
<a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a>
@ -46,24 +46,23 @@
</template>
<script>
const API_DEFAULT_PORT = 3456
export default {
name: 'apiConfig',
data() {
return {
configureApi: false,
apiUrl: window.API_URL,
apiUrl: '',
errorMsg: '',
successMsg: '',
}
},
created() {
this.apiUrl = window.API_URL
if (this.apiUrl === '') {
this.configureApi = true
}
},
computed: {
methods: {
apiDomain() {
if (window.API_URL.startsWith('/api/v1')) {
return window.location.host
@ -73,8 +72,6 @@ export default {
.split(/[/?#]/)
return urlParts[0]
},
},
methods: {
setApiUrl() {
if (this.apiUrl === '') {
return
@ -134,17 +131,17 @@ export default {
return Promise.reject(e)
})
.catch((e) => {
// Check if it is reachable at port API_DEFAULT_PORT and https
if (urlToCheck.port !== API_DEFAULT_PORT) {
// Check if it is reachable at port 3456 and https
if (urlToCheck.port !== 3456) {
urlToCheck.protocol = 'https:'
urlToCheck.port = API_DEFAULT_PORT
urlToCheck.port = 3456
window.API_URL = urlToCheck.toString()
return this.$store.dispatch('config/update')
}
return Promise.reject(e)
})
.catch((e) => {
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and https
// Check if it is reachable at :3456 and /api/v1 and https
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
@ -157,17 +154,17 @@ export default {
return Promise.reject(e)
})
.catch((e) => {
// Check if it is reachable at port API_DEFAULT_PORT and http
if (urlToCheck.port !== API_DEFAULT_PORT) {
// Check if it is reachable at port 3456 and http
if (urlToCheck.port !== 3456) {
urlToCheck.protocol = 'http:'
urlToCheck.port = API_DEFAULT_PORT
urlToCheck.port = 3456
window.API_URL = urlToCheck.toString()
return this.$store.dispatch('config/update')
}
return Promise.reject(e)
})
.catch((e) => {
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and http
// Check if it is reachable at :3456 and /api/v1 and http
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
@ -182,14 +179,14 @@ export default {
.catch(() => {
// Still not found, url is still invalid
this.successMsg = ''
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain()})
window.API_URL = oldUrl
})
.then((r) => {
if (typeof r !== 'undefined') {
// Set it + save it to local storage to save us the hoops
this.errorMsg = ''
this.successMsg = this.$t('apiConfig.success', {domain: this.apiDomain})
this.successMsg = this.$t('apiConfig.success', {domain: this.apiDomain()})
localStorage.setItem('API_URL', window.API_URL)
this.configureApi = false
this.apiUrl = window.API_URL

View File

@ -22,7 +22,7 @@ export default {
type: String,
required: false,
default: '',
},
}
},
}
</script>

View File

@ -2,7 +2,7 @@
<div class="notification is-danger">
<i18n path="loadingError.failed">
<a @click="() => location.reload()">{{ $t('loadingError.tryAgain') }}</a>
<a href="https://vikunja.io/contact/" rel="noreferrer noopener nofollow" target="_blank">{{ $t('loadingError.contact') }}</a>
<a href="https://vikunja.io/contact/">{{ $t('loadingError.contact') }}</a>
</i18n>
</div>
</template>

View File

@ -62,7 +62,7 @@
<script>
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
import Shortcut from '@/components/misc/shortcut.vue'
import Shortcut from '@/components/misc/shortcut'
export default {
name: 'keyboard-shortcuts',

View File

@ -1,8 +1,8 @@
<template>
<div class="legal-links">
<a :href="imprintUrl" rel="noreferrer noopener nofollow" target="_blank" v-if="imprintUrl">{{ $t('navigation.imprint') }}</a>
<a :href="imprintUrl" target="_blank" v-if="imprintUrl">{{ $t('navigation.imprint') }}</a>
<span v-if="imprintUrl && privacyPolicyUrl"> | </span>
<a :href="privacyPolicyUrl" rel="noreferrer noopener nofollow" target="_blank" v-if="privacyPolicyUrl">{{ $t('navigation.privacy') }}</a>
<a :href="privacyPolicyUrl" target="_blank" v-if="privacyPolicyUrl">{{ $t('navigation.privacy') }}</a>
</div>
</template>

View File

@ -6,6 +6,6 @@
<script>
export default {
name: 'nothing',
name: 'nothing'
}
</script>

View File

@ -14,7 +14,7 @@ export default {
keys: {
type: Array,
required: true,
},
}
},
}
</script>

View File

@ -57,7 +57,7 @@ export default {
if (this.disabled) {
return this.$t('task.subscription.subscribedThroughParent', {
entity: this.entity,
parent: this.subscription.entity,
parent: this.subscription.entity
})
}
@ -118,7 +118,7 @@ export default {
.catch(e => {
this.error(e)
})
},
}
},
}
</script>

View File

@ -1,43 +1,53 @@
<template>
<multiselect
:loading="namespaceService.loading"
:placeholder="$t('namespace.search')"
@search="findNamespaces"
:search-results="namespaces"
@select="select"
label="title"
:search-delay="10"
v-model="namespace"
/>
</template>
<script>
import Multiselect from '@/components/input/multiselect.vue'
import NamespaceService from '../../services/namespace'
import NamespaceModel from '../../models/namespace'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'namespace-search',
data() {
return {
query: '',
namespaceService: NamespaceService,
namespace: NamespaceModel,
namespaces: [],
}
},
components: {
Multiselect,
},
computed: {
namespaces() {
if (this.query === '') {
return []
}
return this.$store.state.namespaces.namespaces.filter(n => {
return !n.isArchived &&
n.id > 0 &&
n.title.toLowerCase().includes(this.query.toLowerCase())
})
},
created() {
this.namespaceService = new NamespaceService()
},
methods: {
findNamespaces(query) {
this.query = query
if (query === '') {
this.clearAll()
return
}
this.namespaceService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'namespaces', response)
})
.catch(e => {
this.error(e)
})
},
clearAll() {
this.$set(this, 'namespaces', [])
},
select(namespace) {
this.$emit('selected', namespace)

View File

@ -53,9 +53,9 @@
</template>
<script>
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import TaskSubscription from '@/components/misc/subscription.vue'
import Dropdown from '@/components/misc/dropdown'
import DropdownItem from '@/components/misc/dropdown-item'
import TaskSubscription from '@/components/misc/subscription'
export default {
name: 'namespace-settings-dropdown',

View File

@ -49,8 +49,8 @@
<script>
import NotificationService from '@/services/notification'
import User from '@/components/misc/user.vue'
import names from '@/models/constants/notificationNames.json'
import User from '@/components/misc/user'
import names from '@/models/notificationNames.json'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {mapState} from 'vuex'

View File

@ -62,8 +62,8 @@ import TeamModel from '@/models/team'
import {CURRENT_LIST, LOADING, LOADING_MODULE, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import ListModel from '@/models/list'
import createTask from '@/components/tasks/mixins/createTask'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import {getHistory} from '../../modules/listHistory'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic'
import {getHistory} from '@/modules/listHistory'
const TYPE_LIST = 'list'
const TYPE_TASK = 'task'
@ -127,10 +127,6 @@ export default {
...Object.values(this.$store.state.lists)])]
lists = (allLists.filter(l => {
if (typeof l === 'undefined' || l === null) {
return false
}
if (l.isArchived) {
return false
}
@ -481,7 +477,7 @@ export default {
reset() {
this.query = ''
this.selectedCmd = null
},
}
},
}
</script>

View File

@ -173,7 +173,7 @@
</template>
<script>
import rights from '../../models/constants/rights'
import rights from '../../models/rights'
import LinkShareService from '../../services/linkShare'
import LinkShareModel from '../../models/linkShare'

View File

@ -145,9 +145,9 @@ import TeamListService from '../../services/teamList'
import TeamService from '../../services/team'
import TeamModel from '../../models/team'
import rights from '../../models/constants/rights.json'
import Multiselect from '@/components/input/multiselect.vue'
import Nothing from '@/components/misc/nothing.vue'
import rights from '../../models/rights'
import Multiselect from '@/components/input/multiselect'
import Nothing from '@/components/misc/nothing'
export default {
name: 'userTeamShare',
@ -235,11 +235,11 @@ export default {
this.searchLabel = 'username'
if (this.type === 'list') {
this.typeString = 'list'
this.typeString = `list`
this.stuffService = new UserListService()
this.stuffModel = new UserListModel({listId: this.id})
} else if (this.type === 'namespace') {
this.typeString = 'namespace'
this.typeString = `namespace`
this.stuffService = new UserNamespaceService()
this.stuffModel = new UserNamespaceModel({
namespaceId: this.id,
@ -253,11 +253,11 @@ export default {
this.searchLabel = 'name'
if (this.type === 'list') {
this.typeString = 'list'
this.typeString = `list`
this.stuffService = new TeamListService()
this.stuffModel = new TeamListModel({listId: this.id})
} else if (this.type === 'namespace') {
this.typeString = 'namespace'
this.typeString = `namespace`
this.stuffService = new TeamNamespaceService()
this.stuffModel = new TeamNamespaceModel({
namespaceId: this.id,
@ -278,7 +278,7 @@ export default {
.then((r) => {
this.$set(this, 'sharables', r)
r.forEach((s) =>
this.$set(this.selectedRight, s.id, s.right),
this.$set(this.selectedRight, s.id, s.right)
)
})
.catch((e) => {

View File

@ -1,104 +0,0 @@
<template>
<div class="task-add">
<div class="field is-grouped">
<p class="control has-icons-left is-expanded">
<input
:disabled="taskService.loading"
@keyup.enter="addTask()"
class="input"
:placeholder="$t('list.list.addPlaceholder')"
type="text"
v-focus
v-model="newTaskTitle"
ref="newTaskInput"
@keyup="errorMessage = ''"
/>
<span class="icon is-small is-left">
<icon icon="tasks"/>
</span>
</p>
<p class="control">
<x-button
:disabled="newTaskTitle === '' || taskService.loading"
@click="addTask()"
icon="plus"
:loading="taskService.loading"
>
{{ $t('list.list.add') }}
</x-button>
</p>
</div>
<p class="help is-danger" v-if="errorMessage !== ''">
{{ errorMessage }}
</p>
<quick-add-magic v-if="errorMessage === ''"/>
</div>
</template>
<script>
import TaskService from '../../services/task'
import createTask from '@/components/tasks/mixins/createTask'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
export default {
name: 'add-task',
data() {
return {
newTaskTitle: '',
taskService: TaskService,
errorMessage: '',
}
},
mixins: [
createTask,
],
components: {
QuickAddMagic,
},
created() {
this.taskService = new TaskService()
},
props: {
defaultPosition: {
type: Number,
required: false,
},
},
methods: {
addTask() {
if (this.newTaskTitle === '') {
this.errorMessage = this.$t('list.create.addTitleRequired')
return
}
this.errorMessage = ''
if (this.taskService.loading) {
return
}
this.createNewTask(this.newTaskTitle, 0, this.$store.state.auth.settings.defaultListId, this.defaultPosition)
.then(task => {
this.newTaskTitle = ''
this.$emit('taskAdded', task)
})
.catch(e => {
if (e === 'NO_LIST') {
this.errorMessage = this.$t('list.create.addListRequired')
return
}
this.error(e)
})
},
},
}
</script>
<style lang="scss" scoped>
.task-add {
margin-bottom: 0;
.button {
height: 2.5rem;
}
}
</style>

View File

@ -72,7 +72,7 @@
import ListService from '../../services/list'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import priorities from '../../models/constants/priorities'
import priorities from '../../models/priorities'
import EditLabels from './partials/editLabels'
import Reminders from './partials/reminders'
import ColorPicker from '../input/colorPicker'
@ -100,7 +100,7 @@ export default {
Reminders,
EditLabels,
editor: () => ({
component: import('../../components/input/editor'),
component: import(/* webpackChunkName: "editor" */ '../../components/input/editor'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,

View File

@ -11,7 +11,7 @@
</x-button>
</div>
<filter-popup
@change="loadTasks()"
@change="loadTasks"
:visible="showTaskFilter"
v-model="params"
/>
@ -24,10 +24,18 @@
class="month"
v-for="(m, mk) in days[yk]"
>
{{ formatYear(new Date(`${yk}-${parseInt(mk) + 1}-01`)) }}
{{
new Date(
new Date(yk).setMonth(mk)
).toLocaleString('en-us', {month: 'long'})
}},
{{ new Date(yk).getFullYear() }}
<div class="days">
<div
:class="{ today: d.toDateString() === now.toDateString() }"
:class="{
today:
d.toDateString() === now.toDateString(),
}"
:key="dk + 'day'"
:style="{ width: dayWidth + 'px' }"
class="day"
@ -188,13 +196,12 @@ import EditTask from './edit-task'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import priorities from '../../models/constants/priorities'
import priorities from '../../models/priorities'
import PriorityLabel from './partials/priorityLabel'
import TaskCollectionService from '../../services/taskCollection'
import {mapState} from 'vuex'
import Rights from '../../models/constants/rights.json'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import {format} from 'date-fns'
import Rights from '../../models/rights.json'
import FilterPopup from '@/components/list/partials/filter-popup'
export default {
name: 'GanttChart',
@ -281,12 +288,12 @@ export default {
setDates() {
this.startDate = new Date(this.dateFrom)
this.endDate = new Date(this.dateTo)
console.debug('setDates; start date: ', this.startDate, 'end date:', this.endDate, 'date from:', this.dateFrom, 'date to:', this.dateTo)
this.dayOffsetUntilToday = Math.floor((this.now - this.startDate) / 1000 / 60 / 60 / 24) + 1
this.dayOffsetUntilToday =
Math.floor((this.now - this.startDate) / 1000 / 60 / 60 / 24) +
1
},
prepareGanttDays() {
console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate)
// Layout: years => [months => [days]]
let years = {}
for (
@ -298,13 +305,15 @@ export default {
if (years[date.getFullYear() + ''] === undefined) {
years[date.getFullYear() + ''] = {}
}
if (years[date.getFullYear() + ''][date.getMonth() + ''] === undefined) {
if (
years[date.getFullYear() + ''][date.getMonth() + ''] ===
undefined
) {
years[date.getFullYear() + ''][date.getMonth() + ''] = []
}
years[date.getFullYear() + ''][date.getMonth() + ''].push(date)
this.fullWidth += this.dayWidth
}
console.debug('prepareGanttDays; years:', years)
this.$set(this, 'days', years)
},
parseTasks() {
@ -379,7 +388,7 @@ export default {
let startDate = new Date(this.startDate)
startDate.setDate(
startDate.getDate() + newRect.left / this.dayWidth,
startDate.getDate() + newRect.left / this.dayWidth
)
startDate.setUTCHours(0)
startDate.setUTCMinutes(0)
@ -388,7 +397,7 @@ export default {
this.taskDragged.startDate = startDate
let endDate = new Date(startDate)
endDate.setDate(
startDate.getDate() + newRect.width / this.dayWidth,
startDate.getDate() + newRect.width / this.dayWidth
)
this.taskDragged.startDate = startDate
this.taskDragged.endDate = endDate
@ -431,7 +440,7 @@ export default {
this.$set(
this.theTasks,
tt,
this.addGantAttributes(r),
this.addGantAttributes(r)
)
break
}
@ -479,9 +488,6 @@ export default {
this.error(e)
})
},
formatYear(date) {
return format(date, 'MMMM, yyyy')
},
},
}
</script>

View File

@ -1,4 +1,4 @@
import {parseTaskText} from '@/modules/parseTaskText'
import {parseTaskText} from '@/helpers/parseTaskText'
import TaskModel from '@/models/task'
import {formatISO} from 'date-fns'
import LabelTask from '@/models/labelTask'
@ -6,12 +6,10 @@ import LabelModel from '@/models/label'
import LabelTaskService from '@/services/labelTask'
import {mapState} from 'vuex'
import UserService from '@/services/user'
import TaskService from '@/services/task'
export default {
data() {
return {
taskService: TaskService,
labelTaskService: LabelTaskService,
userService: UserService,
}
@ -19,35 +17,22 @@ export default {
created() {
this.labelTaskService = new LabelTaskService()
this.userService = new UserService()
this.taskService = new TaskService()
},
computed: mapState({
labels: state => state.labels.labels,
}),
methods: {
createNewTask(newTaskTitle, bucketId = 0, lId = 0, position = 0) {
createNewTask(newTaskTitle, bucketId = 0, lId = 0) {
const parsedTask = parseTaskText(newTaskTitle)
const assignees = []
// Uses the following ways to get the list id of the new task:
// 1. If specified in quick add magic, look in store if it exists and use it if it does
// 2. Else check if a list was passed as parameter
// 3. Otherwise use the id from the route parameter
// 4. If none of the above worked, reject the promise with an error.
let listId = null
if (parsedTask.list !== null) {
const list = this.$store.getters['lists/findListByExactname'](parsedTask.list)
listId = list === null ? null : list.id
}
if (lId !== 0) {
listId = lId
}
if (typeof this.$route.params.listId !== 'undefined') {
listId = parseInt(this.$route.params.listId)
}
if (typeof listId === 'undefined' || listId === null) {
return Promise.reject('NO_LIST')
if (listId === null) {
listId = lId !== 0 ? lId : this.$route.params.listId
}
// Separate closure because we need to wait for the results of the user search if users were entered in the
@ -60,7 +45,6 @@ export default {
priority: parsedTask.priority,
assignees: assignees,
bucketId: bucketId,
position: position,
})
return this.taskService.create(task)
.then(task => {
@ -99,7 +83,7 @@ export default {
.then(res => {
return addLabelToTask(res)
})
.catch(e => Promise.reject(e)),
.catch(e => Promise.reject(e))
)
}
})
@ -126,7 +110,7 @@ export default {
assignees.push(user)
}
return Promise.resolve(users)
}),
})
)
})

View File

@ -1,60 +1,5 @@
import TaskCollectionService from '@/services/taskCollection'
import cloneDeep from 'lodash/cloneDeep'
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
// FIXME: merge with DEFAULT_PARAMS in filters.vue
const DEFAULT_PARAMS = {
sort_by: ['position', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
}
function createPagination(totalPages, currentPage) {
const pages = []
for (let i = 0; i < totalPages; i++) {
// Show ellipsis instead of all pages
if (
i > 0 && // Always at least the first page
(i + 1) < totalPages && // And the last page
(
// And the current with current + 1 and current - 1
(i + 1) > currentPage + 1 ||
(i + 1) < currentPage - 1
)
) {
// Only add an ellipsis if the last page isn't already one
if (pages[i - 1] && !pages[i - 1].isEllipsis) {
pages.push({
number: 0,
isEllipsis: true,
})
}
continue
}
pages.push({
number: i + 1,
isEllipsis: false,
})
}
return pages
}
export function getRouteForPagination(page = 1, type = 'list') {
return {
name: 'list.' + type,
params: {
type: type,
},
query: {
page: page,
},
}
}
import TaskCollectionService from '../../../services/taskCollection'
import {cloneDeep} from 'lodash'
/**
* This mixin provides a base set of methods and properties to get tasks on a list.
@ -62,9 +7,10 @@ export function getRouteForPagination(page = 1, type = 'list') {
export default {
data() {
return {
taskCollectionService: new TaskCollectionService(),
taskCollectionService: TaskCollectionService,
tasks: [],
pages: [],
currentPage: 0,
loadedList: null,
@ -73,36 +19,39 @@ export default {
searchTerm: '',
showTaskFilter: false,
params: DEFAULT_PARAMS,
params: {
sort_by: ['done', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
},
}
},
watch: {
// Only listen for query path changes
'$route.query': {
handler: 'loadTasksForPage',
immediate: true,
},
'$route.path': 'loadTasksOnSavedFilter',
'$route.query': 'loadTasksForPage', // Only listen for query path changes
},
computed: {
pages() {
return createPagination(this.taskCollectionService.totalPages, this.currentPage)
},
beforeMount() {
// Triggering loading the tasks in beforeMount lets the component maintain the current page, therefore the page
// is not lost after navigating back from a task detail page for example.
this.loadTasksForPage(this.$route.query)
},
created() {
this.taskCollectionService = new TaskCollectionService()
},
methods: {
loadTasks(
page,
search = '',
params = null,
forceLoading = false,
) {
// Because this function is triggered every time on topNavigation, we're putting a condition here to only load it when we actually want to show tasks
// FIXME: This is a bit hacky -> Cleanup.
if (
this.$route.name !== 'list.list' &&
this.$route.name !== 'list.table' &&
!forceLoading
this.$route.name !== 'list.table'
) {
return
}
@ -123,24 +72,52 @@ export default {
search: search,
page: page,
}
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList) && !forceLoading) {
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList)) {
return
}
this.tasks = []
this.$set(this, 'tasks', [])
this.taskCollectionService.getAll(list, params, page)
.then(r => {
this.tasks = r
this.$set(this, 'tasks', r)
this.$set(this, 'pages', [])
this.currentPage = page
for (let i = 0; i < this.taskCollectionService.totalPages; i++) {
// Show ellipsis instead of all pages
if (
i > 0 && // Always at least the first page
(i + 1) < this.taskCollectionService.totalPages && // And the last page
(
// And the current with current + 1 and current - 1
(i + 1) > this.currentPage + 1 ||
(i + 1) < this.currentPage - 1
)
) {
// Only add an ellipsis if the last page isn't already one
if (this.pages[i - 1] && !this.pages[i - 1].isEllipsis) {
this.pages.push({
number: 0,
isEllipsis: true,
})
}
continue
}
this.pages.push({
number: i + 1,
isEllipsis: false,
})
}
this.loadedList = cloneDeep(currentList)
})
.catch(e => {
this.error(e)
})
},
loadTasksForPage(e) {
// The page parameter can be undefined, in the case where the user loads a new list from the side bar menu
let page = Number(e.page)
@ -153,11 +130,6 @@ export default {
}
this.initTasks(page, search)
},
loadTasksOnSavedFilter() {
if(typeof this.$route.params.listId !== 'undefined' && parseInt(this.$route.params.listId) < 0) {
this.loadTasks(1, '', null, true)
}
},
sortTasks() {
if (this.tasks === null || this.tasks === []) {
return
@ -168,9 +140,9 @@ export default {
if (a.done > b.done)
return 1
if (a.position < b.position)
if (a.id > b.id)
return -1
if (a.position > b.position)
if (a.id < b.id)
return 1
return 0
})
@ -196,23 +168,16 @@ export default {
this.showTaskSearch = false
}, 200)
},
saveTaskPosition(e) {
this.drag = false
const task = this.tasks[e.newIndex]
const taskBefore = this.tasks[e.newIndex - 1] ?? null
const taskAfter = this.tasks[e.newIndex + 1] ?? null
task.position = calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null)
this.$store.dispatch('tasks/update', task)
.then(r => {
this.$set(this.tasks, e.newIndex, r)
})
.catch(e => {
this.error(e)
})
getRouteForPagination(page = 1, type = 'list') {
return {
name: 'list.' + type,
params: {
type: type,
},
query: {
page: page,
},
}
},
getRouteForPagination,
},
}

View File

@ -57,7 +57,7 @@
@click.prevent.stop="downloadAttachment(a)"
v-tooltip="$t('task.attachment.downloadTooltip')"
>
{{ $t('misc.download') }}
{{ $t('task.attachment.download') }}
</a>
<a
@click.stop="copyUrl(a)"
@ -229,7 +229,7 @@ export default {
.then((r) => {
this.$store.commit(
'attachments/removeById',
this.attachmentToDelete.id,
this.attachmentToDelete.id
)
this.success(r)
})

View File

@ -77,8 +77,8 @@
}
"
v-model="c.comment"
:has-edit-bottom="true"
:bottom-actions="actions[c.id]"
:show-save="true"
/>
</div>
</div>
@ -159,7 +159,7 @@ export default {
name: 'comments',
components: {
editor: () => ({
component: import('../../input/editor'),
component: import(/* webpackChunkName: "editor" */ '../../input/editor'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
@ -208,9 +208,6 @@ export default {
watch: {
taskId() {
this.loadComments()
this.newComment.taskId = this.taskId
this.commentEdit.taskId = this.taskId
this.commentToDelete.taskId = this.taskId
},
canWrite() {
this.makeActions()
@ -253,7 +250,6 @@ export default {
this.comments.push(r)
this.newComment.comment = ''
this.success({message: this.$t('task.comment.addedSuccess')})
this.makeActions()
})
.catch((e) => {
this.error(e)

View File

@ -23,15 +23,13 @@
@change="save"
:placeholder="$t('task.description.placeholder')"
:empty-text="$t('task.description.empty')"
:show-save="true"
v-model="task.description"
/>
v-model="task.description"/>
</div>
</template>
<script>
import LoadingComponent from '@/components/misc/loading.vue'
import ErrorComponent from '@/components/misc/error.vue'
import LoadingComponent from '@/components/misc/loading'
import ErrorComponent from '@/components/misc/error'
import {LOADING} from '@/store/mutation-types'
import {mapState} from 'vuex'
@ -40,7 +38,7 @@ export default {
name: 'description',
components: {
editor: () => ({
component: import('@/components/input/editor.vue'),
component: import(/* webpackChunkName: "editor" */ '@/components/input/editor'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
@ -80,9 +78,8 @@ export default {
this.saving = true
this.$store.dispatch('tasks/update', this.task)
.then(t => {
this.task = t
this.$emit('input', t)
.then(() => {
this.$emit('input', this.task)
this.saved = true
setTimeout(() => {
this.saved = false
@ -94,7 +91,7 @@ export default {
.finally(() => {
this.saving = false
})
},
}
},
}
</script>

View File

@ -35,7 +35,7 @@ import UserModel from '../../../models/user'
import ListUserService from '../../../services/listUsers'
import TaskAssigneeService from '../../../services/taskAssignee'
import User from '../../misc/user'
import Multiselect from '@/components/input/multiselect.vue'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'editAssignees',

View File

@ -43,7 +43,7 @@ import differenceWith from 'lodash/differenceWith'
import LabelModel from '../../../models/label'
import LabelTaskService from '../../../services/labelTask'
import Multiselect from '@/components/input/multiselect.vue'
import Multiselect from '@/components/input/multiselect'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
export default {
@ -55,8 +55,7 @@ export default {
},
taskId: {
type: Number,
required: false,
default: () => 0,
required: true,
},
disabled: {
default: false,
@ -101,19 +100,9 @@ export default {
this.query = query
},
addLabel(label, showNotification = true) {
const bubble = () => {
this.$emit('input', this.labels)
this.$emit('change', this.labels)
}
if (this.taskId === 0) {
bubble()
return
}
this.$store.dispatch('tasks/addLabel', {label: label, taskId: this.taskId})
.then(() => {
bubble()
this.$emit('input', this.labels)
if (showNotification) {
this.success({message: this.$t('task.label.addSuccess')})
}
@ -123,24 +112,15 @@ export default {
})
},
removeLabel(label) {
const removeFromState = () => {
for (const l in this.labels) {
if (this.labels[l].id === label.id) {
this.labels.splice(l, 1)
}
}
this.$emit('input', this.labels)
this.$emit('change', this.labels)
}
if (this.taskId === 0) {
removeFromState()
return
}
this.$store.dispatch('tasks/removeLabel', {label: label, taskId: this.taskId})
.then(() => {
removeFromState()
// Remove the label from the list
for (const l in this.labels) {
if (this.labels[l].id === label.id) {
this.labels.splice(l, 1)
}
}
this.$emit('input', this.labels)
this.success({message: this.$t('task.label.removeSuccess')})
})
.catch(e => {
@ -148,10 +128,6 @@ export default {
})
},
createAndAddLabel(title) {
if (this.taskId === 0) {
return
}
const newLabel = new LabelModel({title: title})
this.$store.dispatch('labels/createLabel', newLabel)
.then(r => {

View File

@ -7,17 +7,16 @@
<h1
class="title input"
:class="{'disabled': !canWrite}"
@blur="save($event.target.textContent)"
@keydown.enter.prevent.stop="$event.target.blur()"
@focusout="save()"
@keydown.enter.prevent.stop="save()"
:contenteditable="canWrite ? 'true' : 'false'"
spellcheck="false"
ref="taskTitle">{{ task.title.trim() }}</h1>
<transition name="fade">
<span class="is-inline-flex is-align-items-center" v-if="loading && saving">
<span class="loader is-inline-block mr-2"></span>
{{ $t('misc.saving') }}
</span>
<span class="has-text-success is-inline-flex is-align-content-center" v-if="!loading && showSavedMessage">
<span class="has-text-success is-inline-flex is-align-content-center" v-if="!loading && saved">
<icon icon="check" class="mr-2"/>
{{ $t('misc.saved') }}
</span>
@ -26,22 +25,22 @@
</template>
<script>
import {LOADING} from '@/store/mutation-types'
import {mapState} from 'vuex'
export default {
name: 'heading',
data() {
return {
showSavedMessage: false,
task: {title: '', identifier: '', index:''},
taskTitle: '',
saved: false,
saving: false, // Since loading is global state, this variable ensures we're only showing the saving icon when saving the description.
}
},
computed: {
...mapState(['loading']),
task() {
return this.value
},
},
computed: mapState({
loading: LOADING,
}),
props: {
value: {
required: true,
@ -51,29 +50,43 @@ export default {
default: false,
},
},
watch: {
value(newVal) {
this.task = newVal
this.taskTitle = this.task.title
},
},
mounted() {
this.task = this.value
this.taskTitle = this.task.title
},
methods: {
save(title) {
// We only want to save if the title was actually changed.
// Because the contenteditable does not have a change event
// we're building it ourselves and only continue
// if the task title changed.
if (title === this.task.title) {
return
}
save() {
this.$refs.taskTitle.spellcheck = false
// Pull the task title from the contenteditable
let taskTitle = this.$refs.taskTitle.textContent
this.task.title = taskTitle
// We only want to save if the title was actually change.
// Because the contenteditable does not have a change event,
// we're building it ourselves and only calling saveTask()
// if the task title changed.
if (this.task.title !== this.taskTitle) {
this.$refs.taskTitle.blur()
this.saveTask()
this.taskTitle = taskTitle
}
},
saveTask() {
this.saving = true
const newTask = {
...this.task,
title,
}
this.$store.dispatch('tasks/update', newTask)
.then((task) => {
this.$emit('input', task)
this.showSavedMessage = true
this.$store.dispatch('tasks/update', this.task)
.then(() => {
this.$emit('input', this.task)
this.saved = true
setTimeout(() => {
this.showSavedMessage = false
this.saved = false
}, 2000)
})
.catch(e => {
@ -82,7 +95,7 @@ export default {
.finally(() => {
this.saving = false
})
},
}
},
}
</script>

View File

@ -1,113 +0,0 @@
<template>
<div
:class="{
'is-loading': loadingInternal || loading,
'draggable': !(loadingInternal || loading),
'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}` && task.hexColor !== task.defaultColor,
}"
:style="{'background-color': task.hexColor !== '#' && task.hexColor !== `#${task.defaultColor}` ? task.hexColor : false}"
@click.ctrl="() => markTaskAsDone(task)"
@click.exact="() => $router.push({ name: 'task.kanban.detail', params: { id: task.id } })"
@click.meta="() => markTaskAsDone(task)"
class="task loader-container draggable"
>
<span class="task-id">
<span class="is-done" v-if="task.done">Done</span>
<template v-if="task.identifier === ''">
#{{ task.index }}
</template>
<template v-else>
{{ task.identifier }}
</template>
</span>
<span
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
v-if="task.dueDate > 0"
v-tooltip="formatDate(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<span>
{{ formatDateSince(task.dueDate) }}
</span>
</span>
<h3>{{ task.title }}</h3>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100">
{{ task.percentDone * 100 }}%
</progress>
<div class="footer">
<labels :labels="task.labels"/>
<priority-label :priority="task.priority"/>
<div class="assignees" v-if="task.assignees.length > 0">
<user
:avatar-size="24"
:key="task.id + 'assignee' + u.id"
:show-username="false"
:user="u"
v-for="u in task.assignees"
/>
</div>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span v-if="task.description" class="icon">
<icon icon="align-left"/>
</span>
</div>
</div>
</template>
<script>
import {playPop} from '../../../helpers/playPop'
import PriorityLabel from '../../../components/tasks/partials/priorityLabel'
import User from '../../../components/misc/user'
import Labels from '../../../components/tasks/partials/labels'
export default {
name: 'kanban-card',
components: {
PriorityLabel,
User,
Labels,
},
data() {
return {
loadingInternal: false,
}
},
props: {
task: {
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
markTaskAsDone(task) {
this.loadingInternal = true
this.$store.dispatch('tasks/update', {
...task,
done: !task.done,
})
.then(() => {
if (task.done) {
playPop()
}
})
.catch(e => {
this.error(e)
})
.finally(() => {
this.loadingInternal = false
})
},
},
}
</script>

View File

@ -1,6 +1,7 @@
<template>
<multiselect
class="control is-expanded"
v-focus
:loading="listSerivce.loading"
:placeholder="$t('list.search')"
@search="findLists"
@ -11,7 +12,7 @@
:select-placeholder="$t('list.searchSelect')"
>
<template v-slot:searchResult="props">
<span class="list-namespace-title search-result">{{ namespace(props.option.namespaceId) }} ></span>
<span class="list-namespace-title">{{ namespace(props.option.namespaceId) }} ></span>
{{ props.option.title }}
</template>
</multiselect>
@ -20,7 +21,7 @@
<script>
import ListService from '../../../services/list'
import ListModel from '../../../models/list'
import Multiselect from '@/components/input/multiselect.vue'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'listSearch',
@ -31,11 +32,6 @@ export default {
foundLists: [],
}
},
props: {
value: {
required: false,
},
},
components: {
Multiselect,
},
@ -43,14 +39,6 @@ export default {
this.listSerivce = new ListService()
this.list = new ListModel()
},
watch: {
value(newVal) {
this.list = newVal
},
},
mounted() {
this.list = this.value
},
methods: {
findLists(query) {
if (query === '') {
@ -70,9 +58,7 @@ export default {
this.$set(this, 'foundLists', [])
},
select(list) {
this.list = list
this.$emit('selected', list)
this.$emit('input', list)
},
namespace(namespaceId) {
const namespace = this.$store.getters['namespaces/getNamespaceById'](namespaceId)

View File

@ -21,7 +21,7 @@
</template>
<script>
import priorites from '../../../models/constants/priorities'
import priorites from '../../../models/priorities'
export default {
name: 'priorityLabel',
@ -44,6 +44,8 @@ export default {
</script>
<style lang="scss" scoped>
@import '../../../styles/theme/variables/all';
.priority-label {
display: inline-flex;
align-items: center;

View File

@ -12,7 +12,7 @@
</template>
<script>
import priorites from '../../../models/constants/priorities'
import priorites from '../../../models/priorities'
export default {
name: 'prioritySelect',

View File

@ -122,10 +122,10 @@
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import TaskRelationService from '../../../services/taskRelation'
import relationKinds from '../../../models/constants/relationKinds'
import relationKinds from '../../../models/relationKinds'
import TaskRelationModel from '../../../models/taskRelation'
import Multiselect from '@/components/input/multiselect.vue'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'relatedTasks',
@ -269,6 +269,8 @@ export default {
</script>
<style lang="scss">
@import '@/styles/theme/variables/all';
.add-task-relation-button {
margin-top: -3rem;

View File

@ -26,7 +26,7 @@
</template>
<script>
import datepicker from '@/components/input/datepicker.vue'
import datepicker from '@/components/input/datepicker'
export default {
name: 'reminders',

View File

@ -51,7 +51,7 @@
</template>
<script>
import repeatModes from '@/models/constants/taskRepeatModes'
import repeatModes from '@/models/taskRepeatModes'
export default {
name: 'repeatAfter',
@ -62,7 +62,7 @@ export default {
amount: 0,
type: '',
},
repeatModes,
repeatModes: repeatModes,
}
},
props: {
@ -90,10 +90,6 @@ export default {
},
methods: {
updateData() {
if (this.task.repeatMode !== repeatModes.REPEAT_MODE_DEFAULT && this.repeatAfter.amount === 0) {
return
}
this.task.repeatAfter = this.repeatAfter
this.$emit('input', this.task)
this.$emit('change')

View File

@ -135,7 +135,7 @@ export default {
showListColor: {
type: Boolean,
default: true,
},
}
},
watch: {
theTask(newVal) {
@ -178,13 +178,13 @@ export default {
this.success({
message: this.task.done ?
this.$t('task.doneSuccess') :
this.$t('task.undoneSuccess'),
this.$t('task.undoneSuccess')
}, [{
title: 'Undo',
callback: () => {
this.task.done = !this.task.done
this.markAsDone(!checked)
},
}
}])
})
.catch(e => {

View File

@ -146,6 +146,8 @@ export default {
</script>
<style lang="scss">
@import '../../styles/theme/variables/all';
.cropper {
height: 80vh;
background: transparent;

View File

@ -1,71 +0,0 @@
<template>
<card :title="$t('user.export.title')">
<p>
{{ $t('user.export.description') }}
</p>
<p>
{{ $t('user.export.descriptionPasswordRequired') }}
</p>
<div class="field">
<label class="label" for="currentPasswordDataExport">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordDataExport"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
<x-button
:loading="dataExportService.loading"
@click="requestDataExport()"
class="is-fullwidth mt-4">
{{ $t('user.export.request') }}
</x-button>
</card>
</template>
<script>
import DataExportService from '../../../services/dataExport'
export default {
name: 'data-export',
data() {
return {
dataExportService: DataExportService,
password: '',
errPasswordRequired: false,
}
},
created() {
this.dataExportService = new DataExportService()
},
methods: {
requestDataExport() {
if (this.password === '') {
this.errPasswordRequired = true
this.$refs.passwordInput.focus()
return
}
this.dataExportService.request(this.password)
.then(() => {
this.success({message: this.$t('user.export.success')})
this.password = ''
})
.catch(e => this.error(e))
},
},
}
</script>

View File

@ -1,138 +0,0 @@
<template>
<card :title="$t('user.deletion.title')" v-if="userDeletionEnabled">
<template v-if="deletionScheduledAt !== null">
<form @submit.prevent="cancelDeletion()">
<p>
{{
$t('user.deletion.scheduled', {
date: formatDateShort(deletionScheduledAt),
dateSince: formatDateSince(deletionScheduledAt),
})
}}
</p>
<p>
{{ $t('user.deletion.scheduledCancelText') }}
</p>
<div class="field">
<label class="label" for="currentPasswordAccountDelete">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordAccountDelete"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
</form>
<x-button
:loading="accountDeleteService.loading"
@click="cancelDeletion()"
class="is-fullwidth mt-4">
{{ $t('user.deletion.scheduledCancelConfirm') }}
</x-button>
</template>
<template v-else>
<form @submit.prevent="deleteAccount()">
<p>
{{ $t('user.deletion.text1') }}
</p>
<p>
{{ $t('user.deletion.text2') }}
</p>
<div class="field">
<label class="label" for="currentPasswordAccountDelete">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordAccountDelete"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
</form>
<x-button
:loading="accountDeleteService.loading"
@click="deleteAccount()"
class="is-fullwidth mt-4 is-danger">
{{ $t('user.deletion.confirm') }}
</x-button>
</template>
</card>
</template>
<script>
import AccountDeleteService from '../../../services/accountDelete'
import {mapState} from 'vuex'
import {parseDateOrNull} from '../../../helpers/parseDateOrNull'
export default {
name: 'user-settings-deletion',
data() {
return {
accountDeleteService: AccountDeleteService,
password: '',
errPasswordRequired: false,
}
},
created() {
this.accountDeleteService = new AccountDeleteService()
},
computed: mapState({
userDeletionEnabled: state => state.config.userDeletionEnabled,
deletionScheduledAt: state => parseDateOrNull(state.auth.info.deletionScheduledAt),
}),
methods: {
deleteAccount() {
if (this.password === '') {
this.errPasswordRequired = true
this.$refs.passwordInput.focus()
return
}
this.accountDeleteService.request(this.password)
.then(() => {
this.success({message: this.$t('user.deletion.requestSuccess')})
this.password = ''
})
.catch(e => this.error(e))
},
cancelDeletion() {
if (this.password === '') {
this.errPasswordRequired = true
this.$refs.passwordInput.focus()
return
}
this.accountDeleteService.cancel(this.password)
.then(() => {
this.success({message: this.$t('user.deletion.scheduledCancelSuccess')})
this.$store.dispatch('auth/refreshUserInfo')
this.password = ''
})
.catch(e => this.error(e))
},
},
}
</script>

18
src/helpers/applyDrag.js Normal file
View File

@ -0,0 +1,18 @@
export const applyDrag = (arr, dragResult) => {
const {removedIndex, addedIndex, payload} = dragResult
if (removedIndex === null && addedIndex === null) return arr
const result = [...arr]
// The payload comes from the task itself
let itemToAdd = payload
if (removedIndex !== null) {
itemToAdd = result.splice(removedIndex, 1)[0]
}
if (addedIndex !== null) {
result.splice(addedIndex, 0, itemToAdd)
}
return result
}

View File

@ -1,61 +0,0 @@
import {HTTPFactory} from '@/http-common'
import {AxiosResponse} from 'axios'
let savedToken: string | null = null
/**
* Saves a token while optionally saving it to lacal storage. This is used when viewing a link share:
* It enables viewing multiple link shares indipendently from each in multiple tabs other without overriding any other open ones.
* @param token
* @param persist
*/
export const saveToken = (token: string, persist: boolean) => {
savedToken = token
if (persist) {
localStorage.setItem('token', token)
}
}
/**
* Returns a saved token. If there is one saved in memory it will use that before anything else.
* @returns {string|null}
*/
export const getToken = (): string | null => {
if (savedToken !== null) {
return savedToken
}
savedToken = localStorage.getItem('token')
return savedToken
}
/**
* Removes all tokens everywhere.
*/
export const removeToken = () => {
savedToken = null
localStorage.removeItem('token')
}
/**
* Refreshes an auth token while ensuring it is updated everywhere.
* @returns {Promise<AxiosResponse<any>>}
*/
export const refreshToken = (persist: boolean): Promise<AxiosResponse> => {
const HTTP = HTTPFactory()
return HTTP.post('user/token', null, {
headers: {
Authorization: `Bearer ${getToken()}`,
},
})
.then(r => {
saveToken(r.data.token, persist)
return Promise.resolve(r)
})
.catch(e => {
// eslint-disable-next-line
console.log('Error renewing token: ', e)
return Promise.reject(e)
})
}

View File

@ -1,19 +0,0 @@
export const calculateItemPosition = (positionBefore: number | null, positionAfter: number | null): number => {
if (positionBefore === null && positionAfter === null) {
return 0
}
// If there is no task before, our task is the first task in which case we let it have half of the position of the task after it
if (positionBefore === null && positionAfter !== null) {
return positionAfter / 2
}
// If there is no task after it, we just add 2^16 to the last position to have enough room in the future
if (positionBefore !== null && positionAfter === null) {
return positionBefore + Math.pow(2, 16)
}
// If we have both a task before and after it, we acually calculate the position
// @ts-ignore - can never be null but TS does not seem to understand that
return positionBefore + (positionAfter - positionBefore) / 2
}

View File

@ -1,18 +0,0 @@
import {calculateItemPosition} from './calculateItemPosition'
it('should calculate the task position', () => {
const result = calculateItemPosition(10, 100)
expect(result).toBe(55)
})
it('should return 0 if no position was provided', () => {
const result = calculateItemPosition(null, null)
expect(result).toBe(0)
})
it('should calculate the task position for the first task', () => {
const result = calculateItemPosition(null, 100)
expect(result).toBe(50)
})
it('should calculate the task position for the last task', () => {
const result = calculateItemPosition(10, null)
expect(result).toBe(65546)
})

View File

@ -1,7 +0,0 @@
export const downloadBlob = (url: string, filename: string) => {
const link = document.createElement('a')
link.href = url
link.setAttribute('download', filename)
link.click()
window.URL.revokeObjectURL(url)
}

View File

@ -1,3 +0,0 @@
export function findIndexById(array : [], id : string | number) {
return array.findIndex(({id: currentId}) => currentId === id)
}

View File

@ -1,6 +1,6 @@
export const getListTitle = (l, $t) => {
if (l.id === -1) {
return $t('list.pseudo.favorites.title')
return $t('list.pseudo.favorites.title');
}
return l.title
return l.title;
}

View File

@ -1,12 +1,12 @@
export const getNamespaceTitle = (n, $t) => {
if (n.id === -1) {
return $t('namespace.pseudo.sharedLists.title')
return $t('namespace.pseudo.sharedLists.title');
}
if (n.id === -2) {
return $t('namespace.pseudo.favorites.title')
return $t('namespace.pseudo.favorites.title');
}
if (n.id === -3) {
return $t('namespace.pseudo.savedFilters.title')
return $t('namespace.pseudo.savedFilters.title');
}
return n.title
return n.title;
}

View File

@ -1,38 +0,0 @@
export interface Migrator {
name: string
identifier: string
isFileMigrator?: boolean
}
export const getMigratorFromSlug = (slug: string): Migrator => {
switch (slug) {
case 'wunderlist':
return {
name: 'Wunderlist',
identifier: 'wunderlist',
}
case 'todoist':
return {
name: 'Todoist',
identifier: 'todoist',
}
case 'trello':
return {
name: 'Trello',
identifier: 'trello',
}
case 'microsoft-todo':
return {
name: 'Microsoft Todo',
identifier: 'microsoft-todo',
}
case 'vikunja-file':
return {
name: 'Vikunja Export',
identifier: 'vikunja-file',
isFileMigrator: true,
}
default:
throw Error('Unknown migrator slug ' + slug)
}
}

View File

@ -1,38 +1,18 @@
import {parseDate} from '../helpers/time/parseDate'
import _priorities from '../models/constants/priorities.json'
import {parseDate} from './time/parseDate'
import priorities from '../models/priorities.json'
const LABEL_PREFIX: string = '@'
const LIST_PREFIX: string = '#'
const PRIORITY_PREFIX: string = '!'
const ASSIGNEE_PREFIX: string = '+'
const priorities: Priorites = _priorities
interface Priorites {
UNSET: number,
LOW: number,
MEDIUM: number,
HIGH: number,
URGENT: number,
DO_NOW: number,
}
interface ParsedTaskText {
text: string,
date: Date | null,
labels: string[],
list: string | null,
priority: number | null,
assignees: string[],
}
const LABEL_PREFIX = '@'
const LIST_PREFIX = '#'
const PRIORITY_PREFIX = '!'
const ASSIGNEE_PREFIX = '+'
/**
* Parses task text for dates, assignees, labels, lists, priorities and returns an object with all found intents.
*
* @param text
*/
export const parseTaskText = (text: string): ParsedTaskText => {
const result: ParsedTaskText = {
export const parseTaskText = text => {
const result = {
text: text,
date: null,
labels: [],
@ -43,7 +23,7 @@ export const parseTaskText = (text: string): ParsedTaskText => {
result.labels = getItemsFromPrefix(text, LABEL_PREFIX)
const lists: string[] = getItemsFromPrefix(text, LIST_PREFIX)
const lists = getItemsFromPrefix(text, LIST_PREFIX)
result.list = lists.length > 0 ? lists[0] : null
result.priority = getPriority(text)
@ -57,8 +37,8 @@ export const parseTaskText = (text: string): ParsedTaskText => {
return cleanupResult(result)
}
const getItemsFromPrefix = (text: string, prefix: string): string[] => {
const items: string[] = []
const getItemsFromPrefix = (text, prefix) => {
const items = []
const itemParts = text.split(prefix)
itemParts.forEach((p, index) => {
@ -68,10 +48,10 @@ const getItemsFromPrefix = (text: string, prefix: string): string[] => {
}
let labelText
if (p.charAt(0) === '\'') {
labelText = p.split('\'')[1]
} else if (p.charAt(0) === '"') {
labelText = p.split('"')[1]
if (p.charAt(0) === `'`) {
labelText = p.split(`'`)[1]
} else if (p.charAt(0) === `"`) {
labelText = p.split(`"`)[1]
} else {
// Only until the next space
labelText = p.split(' ')[0]
@ -82,15 +62,15 @@ const getItemsFromPrefix = (text: string, prefix: string): string[] => {
return Array.from(new Set(items))
}
const getPriority = (text: string): number | null => {
const getPriority = text => {
const ps = getItemsFromPrefix(text, PRIORITY_PREFIX)
if (ps.length === 0) {
return null
}
for (const p of ps) {
for (const pi of Object.values(priorities)) {
if (pi === parseInt(p)) {
for (const pi in priorities) {
if (priorities[pi] === parseInt(p)) {
return parseInt(p)
}
}
@ -99,7 +79,7 @@ const getPriority = (text: string): number | null => {
return null
}
const cleanupItemText = (text: string, items: string[], prefix: string): string => {
const cleanupItemText = (text, items, prefix) => {
items.forEach(l => {
text = text
.replace(`${prefix}'${l}' `, '')
@ -112,10 +92,10 @@ const cleanupItemText = (text: string, items: string[], prefix: string): string
return text
}
const cleanupResult = (result: ParsedTaskText): ParsedTaskText => {
const cleanupResult = result => {
result.text = cleanupItemText(result.text, result.labels, LABEL_PREFIX)
result.text = result.list !== null ? cleanupItemText(result.text, [result.list], LIST_PREFIX) : result.text
result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], PRIORITY_PREFIX) : result.text
result.text = cleanupItemText(result.text, [result.list], LIST_PREFIX)
result.text = cleanupItemText(result.text, [result.priority], PRIORITY_PREFIX)
result.text = cleanupItemText(result.text, result.assignees, ASSIGNEE_PREFIX)
result.text = result.text.trim()

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