Compare commits

..

6 Commits

Author SHA1 Message Date
kolaente 1817a1f958
Add background stripes and such 2021-05-20 17:44:25 +02:00
kolaente affaa95e3f
Add pen icon to link to task detail 2021-05-20 17:15:53 +02:00
kolaente 7e6d5956ba
Make it look more like the old gantt chart 2021-05-20 14:14:10 +02:00
kolaente a1ee90df33
Add dragged handler 2021-05-19 23:01:09 +02:00
kolaente 1604f5b4aa
Color 2021-05-19 19:44:55 +02:00
kolaente 6a0427b216
Start adding vue-ganttastic 2021-05-19 19:43:25 +02:00
282 changed files with 15620 additions and 23978 deletions

View File

@ -12,97 +12,75 @@ trigger:
services:
- name: api
image: vikunja/api:unstable
image: vikunja/api
environment:
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
VIKUNJA_LOG_LEVEL: DEBUG
steps:
# Disabled until we figure out why it is so slow
# - name: restore-cache
# image: meltwater/drone-cache:dev
# pull: true
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
# AWS_SECRET_ACCESS_KEY:
# from_secret: cache_aws_secret_access_key
# settings:
# restore: true
# bucket: kolaente.dev-drone-dependency-cache
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - '.cache'
- name: restore-cache
image: meltwater/drone-cache:dev
pull: true
environment:
AWS_ACCESS_KEY_ID:
from_secret: cache_aws_access_key_id
AWS_SECRET_ACCESS_KEY:
from_secret: cache_aws_secret_access_key
settings:
restore: true
bucket: kolaente.dev-drone-dependency-cache
endpoint: https://s3.fr-par.scw.cloud
region: fr-par
path_style: true
cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
mount:
- '.cache'
- name: dependencies
image: node:16
image: node:12
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- yarn --frozen-lockfile --network-timeout 100000
# depends_on:
# - restore-cache
depends_on:
- restore-cache
# - name: rebuild-cache
# image: meltwater/drone-cache:dev
# pull: true
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
# AWS_SECRET_ACCESS_KEY:
# from_secret: cache_aws_secret_access_key
# settings:
# rebuild: true
# bucket: kolaente.dev-drone-dependency-cache
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - '.cache'
# depends_on:
# - dependencies
- name: rebuild-cache
image: meltwater/drone-cache:dev
pull: true
environment:
AWS_ACCESS_KEY_ID:
from_secret: cache_aws_access_key_id
AWS_SECRET_ACCESS_KEY:
from_secret: cache_aws_secret_access_key
settings:
rebuild: true
bucket: kolaente.dev-drone-dependency-cache
endpoint: https://s3.fr-par.scw.cloud
region: fr-par
path_style: true
cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
mount:
- '.cache'
depends_on:
- dependencies
- name: lint
image: node:16
- name: build
image: node:12
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
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
- name: test-unit
image: node:16
image: node:12
pull: true
commands:
- yarn test:unit
@ -110,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: 10000
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
@ -167,26 +144,26 @@ steps:
commands:
- git fetch --tags
# - name: restore-cache
# image: meltwater/drone-cache:dev
# pull: true
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
# AWS_SECRET_ACCESS_KEY:
# from_secret: cache_aws_secret_access_key
# settings:
# restore: true
# bucket: kolaente.dev-drone-dependency-cache
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - '.cache'
- name: restore-cache
image: meltwater/drone-cache:dev
pull: true
environment:
AWS_ACCESS_KEY_ID:
from_secret: cache_aws_access_key_id
AWS_SECRET_ACCESS_KEY:
from_secret: cache_aws_secret_access_key
settings:
restore: true
bucket: kolaente.dev-drone-dependency-cache
endpoint: https://s3.fr-par.scw.cloud
region: fr-par
path_style: true
cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
mount:
- '.cache'
- name: build
image: node:16
image: node:12
pull: true
group: build-static
environment:
@ -197,8 +174,8 @@ steps:
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- yarn run build
- sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on:
# - restore-cache
depends_on:
- restore-cache
- name: static
image: kolaente/zip
@ -242,26 +219,26 @@ steps:
commands:
- git fetch --tags
# - name: restore-cache
# image: meltwater/drone-cache:dev
# pull: true
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
# AWS_SECRET_ACCESS_KEY:
# from_secret: cache_aws_secret_access_key
# settings:
# restore: true
# bucket: kolaente.dev-drone-dependency-cache
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - '.cache'
- name: restore-cache
image: meltwater/drone-cache:dev
pull: true
environment:
AWS_ACCESS_KEY_ID:
from_secret: cache_aws_access_key_id
AWS_SECRET_ACCESS_KEY:
from_secret: cache_aws_secret_access_key
settings:
restore: true
bucket: kolaente.dev-drone-dependency-cache
endpoint: https://s3.fr-par.scw.cloud
region: fr-par
path_style: true
cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
mount:
- '.cache'
- name: build
image: node:16
image: node:12
pull: true
group: build-static
environment:
@ -272,8 +249,8 @@ steps:
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- yarn run build
- sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on:
# - restore-cache
depends_on:
- restore-cache
- name: static
image: kolaente/zip
@ -311,7 +288,7 @@ trigger:
- push
depends_on:
- release-latest
- release-latest
steps:
- name: trigger
@ -342,7 +319,7 @@ trigger:
- "refs/tags/**"
steps:
- name: docker-unstable
- name: docker-latest
image: plugins/docker:linux-arm
pull: true
settings:
@ -351,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
@ -381,7 +358,7 @@ steps:
depends_on:
- clone
- name: docker-unstable-arm64
- name: docker-latest-arm64
image: plugins/docker:linux-arm64
pull: true
settings:
@ -390,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
@ -439,7 +416,7 @@ trigger:
- "refs/tags/**"
steps:
- name: docker-unstable
- name: docker-latest
image: plugins/docker:linux-amd64
pull: true
settings:
@ -448,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
@ -489,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:
@ -517,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
@ -568,66 +528,3 @@ steps:
status:
- success
- failure
---
kind: pipeline
type: docker
name: update-translations
depends_on:
- build
trigger:
branch:
- main
event:
- 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
image: appleboy/drone-git-push
depends_on:
- move-files
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"
ssh_key:
from_secret: translation_git_push_ssh_key
- name: upload
pull: always
image: jonasfranz/crowdin
depends_on:
- clone
settings:
files:
en.json: src/i18n/lang/en.json
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key

View File

@ -1,25 +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
[.nvmrc]
insert_final_newline = false

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

1
.nvmrc
View File

@ -1 +0,0 @@
v16

View File

@ -1,12 +0,0 @@
{
"recommendations": [
"codezombiech.gitignore",
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"johnsoncodehk.volar",
"lokalise.i18n-ally",
"mgmcdermott.vscode-language-babel",
"mikestead.dotenv",
"Syler.sass-indented"
]
}

View File

@ -1,30 +0,0 @@
{
"eslint.packageManager": "yarn",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"eslint.format.enable": true,
"[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
// https://eslint.vuejs.org/user-guide/#editor-integrations
"eslint.validate": [
"javascript",
"javascriptreact",
"vue"
],
"vetur.validation.template": false,
// i18n ally
"i18n-ally.localesPaths": [
"src/i18n/lang"
],
"i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": true,
"i18n-ally.keystyle": "nested",
}

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

@ -1,5 +1,5 @@
# Stage 1: Build application
FROM node:16 AS compile-image
FROM node:13.14.0 AS compile-image
WORKDIR /build

View File

@ -4,8 +4,7 @@
[![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)
This is the web frontend for Vikunja, written in Vue.js.
@ -20,25 +19,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,11 +1,8 @@
{
"baseUrl": "http://localhost:5000",
"baseUrl": "http://localhost:8080",
"env": {
"API_URL": "http://localhost:3456/api/v1",
"TEST_SECRET": "testingS3cr3et"
},
"video": false,
"retries": {
"runMode": 2
}
"video": false
}

View File

@ -9,8 +9,8 @@
## Fixtures
We're using the [test endpoint](https://vikunja.io/docs/config-options/#testingtoken) of the vikunja api to
seed the database with test data before running the tests.
We're using the [test endpoint](https://vikunja.io/docs/config-options/#testingtoken) of the vikunja api to
seed the database with test data before running the tests.
This ensures better reproducability of tests.
## Running The Tests Locally
@ -22,20 +22,20 @@ It uses the same configuration as the CI.
To use it, run
```shell
```
docker-compose up -d
```
Then, once all containers are started, run
```shell
docker-compose run cypress bash
```
docker-composer run cypress bash
```
to get a shell inside the cypress container.
In that shell you can then execute the tests with
```shell
```
yarn test:frontend
```
@ -43,6 +43,6 @@ yarn test:frontend
To open the Cypress Dashboard and run tests from there, run
```shell
```
yarn cypress:open
```

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

@ -10,12 +10,10 @@ import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
describe('Lists', () => {
let lists
beforeEach(() => {
UserFactory.create(1)
NamespaceFactory.create(1)
lists = ListFactory.create(1, {
const lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
@ -56,64 +54,6 @@ describe('Lists', () => {
.should('contain', '/lists/1/kanban')
})
it('Should rename the list in all places', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
const newListName = 'New list name'
cy.visit('/lists/1')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
.type(`{selectall}${newListName}`)
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.list-title h1')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content .tasks')
.should('contain', newListName)
.should('not.contain', lists[0].title)
})
it('Should remove a list', () => {
cy.visit(`/lists/${lists[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list')
.should('not.contain', lists[0].title)
cy.location('pathname')
.should('equal', '/')
})
describe('List View', () => {
it('Should be an empty list', () => {
cy.visit('/lists/1')
@ -253,24 +193,20 @@ 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', () => {
const now = new Date()
const nextMonth = now
nextMonth.setDate(1)
nextMonth.setMonth(now.getMonth() + 1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .months')
.should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
.should('contain', format(now.setMonth(now.getMonth() + 1), 'MMMM'))
})
it('Shows tasks with dates', () => {
@ -388,7 +324,7 @@ describe('Lists', () => {
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not Set')
.contains('Limit: Not set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
@ -436,23 +372,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, {
@ -462,7 +401,7 @@ describe('Lists', () => {
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
cy.getAttached('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
@ -484,7 +423,7 @@ describe('Lists', () => {
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
cy.getAttached('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
@ -508,34 +447,4 @@ describe('Lists', () => {
.should('not.contain', task.title)
})
})
describe('List history', () => {
it('should show a list history on the home page', () => {
const lists = ListFactory.create(6)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('not.exist')
cy.visit(`/lists/${lists[0].id}`)
cy.visit(`/lists/${lists[1].id}`)
cy.visit(`/lists/${lists[2].id}`)
cy.visit(`/lists/${lists[3].id}`)
cy.visit(`/lists/${lists[4].id}`)
cy.visit(`/lists/${lists[5].id}`)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('exist')
cy.get('.list-cards-wrapper-2-rows')
.should('not.contain', lists[0].title)
.should('contain', lists[1].title)
.should('contain', lists[2].title)
.should('contain', lists[3].title)
.should('contain', lists[4].title)
.should('contain', lists[5].title)
})
})
})

View File

@ -20,126 +20,20 @@ describe('Namepaces', () => {
})
it('Should create a new Namespace', () => {
const newNamespaceTitle = 'New Namespace'
cy.visit('/namespaces')
cy.get('a.button')
.contains('Create a new namespace')
.contains('Create namespace')
.click()
cy.url()
.should('contain', '/namespaces/new')
cy.get('.card-header-title')
.should('contain', 'Create a new namespace')
cy.get('input.input')
.type(newNamespaceTitle)
.type('New Namespace')
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container')
.should('contain', newNamespaceTitle)
cy.url()
.should('contain', '/namespaces')
})
it('Should rename the namespace all places', () => {
const newNamespaces = NamespaceFactory.create(5)
const newNamespaceName = 'New namespace name'
cy.visit('/namespaces')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.url()
.should('contain', '/settings/edit')
cy.get('#namespacetext')
.invoke('val')
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
cy.get('#namespacetext')
.type(`{selectall}${newNamespaceName}`)
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
cy.get('.content.namespaces-list')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
})
it('Should remove a namespace when deleting it', () => {
const newNamespaces = NamespaceFactory.create(5)
cy.visit('/')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
.should('not.contain', newNamespaces[0].title)
})
it('Should not show archived lists & namespaces if the filter is not checked', () => {
const n = NamespaceFactory.create(1, {
id: 2,
is_archived: true,
}, false)
ListFactory.create(1, {
id: 2,
namespace_id: n[0].id,
}, false)
ListFactory.create(1, {
id: 3,
is_archived: true,
}, false)
// Initial
cy.visit('/namespaces')
cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived')
// Show archived
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
.should('be.visible')
.click()
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('be.checked')
cy.get('.namespaces-list .namespace')
.should('contain', 'Archived')
// Don't show archived
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
.should('be.visible')
.click()
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('not.be.checked')
// Second time visiting after unchecking
cy.visit('/namespaces')
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('not.be.checked')
cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived')
})
})

View File

@ -1,35 +0,0 @@
import '../../support/authenticateUser'
const setHours = hours => {
const date = new Date()
date.setHours(hours)
cy.clock(+date)
}
describe('Home Page', () => {
it('shows the right salutation in the night', () => {
setHours(4)
cy.visit('/')
cy.get('h2').should('contain', 'Good Night')
})
it('shows the right salutation in the morning', () => {
setHours(8)
cy.visit('/')
cy.get('h2').should('contain', 'Good Morning')
})
it('shows the right salutation in the day', () => {
setHours(13)
cy.visit('/')
cy.get('h2').should('contain', 'Hi')
})
it('shows the right salutation in the night', () => {
setHours(20)
cy.visit('/')
cy.get('h2').should('contain', 'Good Evening')
})
it('shows the right salutation in the night again', () => {
setHours(23)
cy.visit('/')
cy.get('h2').should('contain', 'Good Night')
})
})

View File

@ -11,7 +11,7 @@ describe('Team', () => {
const newTeamName = 'New Team'
cy.get('a.button')
.contains('Create a new team')
.contains('New Team')
.click()
cy.url()
.should('contain', '/teams/new')
@ -113,7 +113,7 @@ describe('Team', () => {
cy.get('.card')
.contains('Team Members')
.get('.card-content .button')
.contains('Add to team')
.contains('Add To Team')
.click()
cy.get('table.table td')

View File

@ -11,7 +11,6 @@ import '../../support/authenticateUser'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket'
describe('Task', () => {
let namespaces
@ -27,7 +26,7 @@ describe('Task', () => {
it('Should be created new', () => {
cy.visit('/lists/1/list')
cy.get('.input[placeholder="Add a new task…"')
cy.get('input.input[placeholder="Add a new task..."')
.type('New Task')
cy.get('.button')
.contains('Add')
@ -43,7 +42,7 @@ describe('Task', () => {
cy.visit('/lists/1/list')
cy.get('.list-is-empty-notice')
.should('not.exist')
cy.get('.input[placeholder="Add a new task…"')
cy.get('input.input[placeholder="Add a new task..."')
.type('New Task')
cy.get('.button')
.contains('Add')
@ -113,10 +112,9 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .heading .is-done')
.should('be.visible')
.should('exist')
.should('contain', 'Done')
cy.get('.task-view .action-buttons p.created')
.should('be.visible')
.should('contain', 'Done')
})
@ -169,7 +167,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')
@ -184,11 +182,9 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .comments .media.comment .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.should('be.visible')
.type('{selectall}New Comment')
cy.get('.task-view .comments .media.comment .button:not([disabled])')
.contains('Comment')
.should('be.visible')
.click()
cy.get('.task-view .comments .media.comment .editor')
@ -199,9 +195,6 @@ describe('Task', () => {
it('Can move a task to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}'
})
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
@ -235,7 +228,6 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.should('be.visible')
.contains('Delete task')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
@ -318,7 +310,6 @@ describe('Task', () => {
cy.get('.task-view .action-buttons .button')
.contains('Add labels')
.should('be.visible')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(newLabelText)
@ -374,7 +365,6 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
.should('be.visible')
.should('contain', labels[0].title)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
.children()

View File

@ -34,7 +34,6 @@ context('Login', () => {
cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})

View File

@ -28,7 +28,6 @@ context('Registration', () => {
cy.get('#password2').type(fixture.password)
cy.get('#register-submit').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})

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,33 +1,17 @@
/**
* Recursively gets an element, returning only after it's determined to be attached to the DOM for good.
* getAttached(selector)
* getAttached(selectorFn)
*
* Source: https://github.com/cypress-io/cypress/issues/7306#issuecomment-850621378
* Waits until the selector finds an attached element, then yields it (wrapped).
* selectorFn, if provided, is passed $(document). Don't use cy methods inside selectorFn.
*
* Source: https://github.com/cypress-io/cypress/issues/5743#issuecomment-650421731
*/
Cypress.Commands.add('getSettled', (selector, opts = {}) => {
const retries = opts.retries || 3
const delay = opts.delay || 100
const isAttached = (resolve, count = 0) => {
const el = Cypress.$(selector)
// is element attached to the DOM?
count = Cypress.dom.isAttached(el) ? count + 1 : 0
// hit our base case, return the element
if (count >= retries) {
return resolve(el)
}
// retry after a bit of a delay
setTimeout(() => isAttached(resolve, count), delay)
}
// wrap, so we can chain cypress commands off the result
return cy.wrap(null).then(() => {
return new Cypress.Promise((resolve) => {
return isAttached(resolve, 0)
}).then((el) => {
return cy.wrap(el)
})
})
})
Cypress.Commands.add('getAttached', selector => {
const getElement = typeof selector === 'function' ? selector : $d => $d.find(selector);
let $el = null;
return cy.document().should($d => {
$el = getElement(Cypress.$($d));
expect(Cypress.dom.isDetached($el)).to.be.false;
}).then(() => cy.wrap($el));
});

View File

@ -21,7 +21,7 @@ export class Factory {
* @param override
* @returns {[]}
*/
static create(count = 1, override = {}, truncate = true) {
static create(count = 1, override = {}) {
const data = []
for (let i = 1; i <= count; i++) {
@ -38,7 +38,7 @@ export class Factory {
data.push(entry)
}
seed(this.table, data, truncate)
seed(this.table, data)
return data
}

View File

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

View File

@ -8,14 +8,14 @@
* @param table
* @param data
*/
export function seed(table, data = {}, truncate = true) {
if (data === null) {
export function seed(table, data = {}) {
if(data === null) {
data = []
}
cy.request({
method: 'PATCH',
url: `${Cypress.env('API_URL')}/test/${table}?truncate=${truncate ? 'true' : 'false'}`,
url: `${Cypress.env('API_URL')}/test/${table}`,
headers: {
'Authorization': Cypress.env('TEST_SECRET'),
},

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,36 +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-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,116 +3,79 @@
"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.1",
"bulma": "0.9.3",
"browserslist": "4.16.6",
"bulma": "0.9.2",
"camel-case": "4.1.2",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.24.0",
"dompurify": "2.3.3",
"highlight.js": "11.2.0",
"is-touch-device": "1.0.1",
"date-fns": "2.21.3",
"dompurify": "2.2.8",
"highlight.js": "10.7.2",
"lodash": "4.17.21",
"marked": "3.0.4",
"marked": "2.0.3",
"moment": "^2.29.1",
"register-service-worker": "1.7.2",
"sass": "1.32.13",
"snake-case": "3.0.4",
"ufo": "0.7.9",
"verte": "0.0.12",
"vue": "2.6.14",
"vue-advanced-cropper": "1.8.2",
"vue": "2.6.12",
"vue-advanced-cropper": "1.5.2",
"vue-drag-resize": "1.5.4",
"vue-easymde": "1.4.0",
"vue-i18n": "8.26.2",
"vue-ganttastic": "^0.9.32",
"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.2",
"@typescript-eslint/eslint-plugin": "4.32.0",
"@typescript-eslint/parser": "4.32.0",
"@vue/babel-preset-app": "4.5.13",
"@vue/eslint-config-typescript": "7.0.0",
"@vue/runtime-dom": "latest",
"autoprefixer": "10.3.6",
"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.5.0",
"cypress-file-upload": "5.0.8",
"esbuild": "0.13.3",
"eslint": "7.32.0",
"eslint-plugin-vue": "7.18.0",
"express": "4.17.1",
"cypress": "7.2.0",
"cypress-file-upload": "5.0.7",
"eslint": "7.26.0",
"eslint-plugin-vue": "7.9.0",
"faker": "5.5.3",
"jest": "27.2.4",
"rollup-plugin-terser": "7.0.2",
"rollup-plugin-visualizer": "5.5.2",
"sass": "1.42.1",
"ts-jest": "27.0.5",
"typescript": "4.4.3",
"vite": "2.6.1",
"vite-plugin-pwa": "0.11.2",
"vite-plugin-vue2": "1.8.2",
"vue-flatpickr-component": "8.1.7",
"jest": "26.6.3",
"sass-loader": "10.2.0",
"vue-flatpickr-component": "8.1.6",
"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"
"vue-router": "3.5.1",
"vue-template-compiler": "2.6.12",
"wait-on": "5.3.0"
},
"eslintConfig": {
"root": true,
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:vue/essential",
"@vue/typescript"
"eslint:recommended"
],
"rules": {
"vue/html-quotes": [
"error",
"double"
],
"quotes": [
"error",
"single"
],
"comma-dangle": [
"error",
"always-multiline"
],
"semi": [
"error",
"never"
]
},
"parser": "vue-eslint-parser",
"rules": {},
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": 2021
"parser": "babel-eslint"
},
"ignorePatterns": [
"*.test.*",
"*.test.js",
"cypress/*"
]
},
@ -124,27 +87,12 @@
"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"
}
}

View File

@ -1,8 +0,0 @@
#!/bin/sh
set -e
# Shell script because yaml doesn't understand the header is a string literal and not a yaml symbol
curl -d operation=pull -H "Authorization: Token $WEBLATE_TOKEN" https://hosted.weblate.org/api/projects/vikunja/repository/
curl -d operation=push -H "Authorization: Token $WEBLATE_TOKEN" https://hosted.weblate.org/api/projects/vikunja/repository/

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.

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>

10
run.sh
View File

@ -2,19 +2,15 @@
# This shell script sets the api url based on an environment variable and starts nginx in foreground.
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTP_PORT:-443}"
if [ -z "$VIKUNJA_API_URL" ]; then
VIKUNJA_API_URL="/api/v1"
fi
# Escape the variable to prevent sed from complaining
VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g')
sed -i "s/http\:\/\/localhost\:3456\/api\/v1/$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
sed -i "s/listen 80/listen $VIKUNJA_HTTP_PORT/g" nginx.conf
sed -i "s/listen 443/listen $VIKUNJA_HTTPS_PORT/g" nginx.conf
# Set the uid and gid of the nginx run user
usermod --non-unique --uid ${PUID} nginx
groupmod --non-unique --gid ${PGID} nginx

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,17 @@
</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'
export default {
name: 'app',
@ -50,16 +49,10 @@ 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()
},
created() {
// Make sure to always load the home route when running with electron
@ -67,19 +60,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 +86,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.$message.success({message: this.$t('user.deletion.confirmSuccess')})
this.$store.dispatch('auth/refreshUserInfo')
})
.catch(e => this.$message.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,21 +5,21 @@
</a>
<div
:class="{'has-background': background}"
:style="{'background-image': background && `url(${background})`}"
:style="{'background-image': `url(${background})`}"
class="app-container"
>
<navigation/>
<div
:class="[
{ 'is-menu-enabled': menuActive },
{
'is-menu-enabled': menuActive,
},
$route.name,
]"
class="app-content"
>
<a @click="$store.commit('menuActive', false)" class="mobile-overlay" v-if="menuActive"></a>
<quick-actions/>
<router-view/>
<transition name="modal">
@ -42,23 +42,22 @@
<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'
export default {
name: 'contentAuth',
components: {QuickActions, Navigation},
components: {Navigation},
watch: {
'$route': {
handler: 'doStuffAfterRoute',
deep: true,
},
'$route': 'doStuffAfterRoute',
},
created() {
this.renewTokenOnFocus()
this.loadLabels()
},
computed: mapState({
namespaces(state) {
return state.namespaces.namespaces.filter(n => !n.isArchived)
},
currentList: CURRENT_LIST,
background: 'background',
menuActive: MENU_ACTIVE,
userInfo: state => state.auth.info,
@ -84,7 +83,7 @@ export default {
this.$route.name === 'user.settings' ||
this.$route.name === 'namespaces.index'
) {
this.$store.commit(CURRENT_LIST, null)
this.$store.commit(CURRENT_LIST, {})
}
},
renewTokenOnFocus() {
@ -124,12 +123,6 @@ export default {
showKeyboardShortcuts() {
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
},
loadLabels() {
this.$store.dispatch('labels/loadAllLabels')
.catch(e => {
this.$message.error(e)
})
},
},
}
</script>

View File

@ -10,12 +10,20 @@
<h1
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
{{ currentList.title === '' ? 'Loading...' : currentList.title }}
</h1>
<div class="box has-text-left view">
<div class="logout">
<x-button @click="logout()" type="secondary">
<span>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">
{{ $t('misc.poweredBy') }}
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank">
Powered by Vikunja
</a>
</div>
</div>

View File

@ -1,10 +1,10 @@
<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>
<p>Info</p>
</div>
<div class="message-body">
{{ motd }}

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>
@ -10,7 +10,7 @@
<span class="icon">
<icon icon="calendar"/>
</span>
{{ $t('navigation.overview') }}
Overview
</router-link>
</li>
<li>
@ -18,7 +18,7 @@
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
{{ $t('navigation.upcoming') }}
Upcoming
</router-link>
</li>
<li>
@ -26,7 +26,7 @@
<span class="icon">
<icon icon="layer-group"/>
</span>
{{ $t('namespace.title') }}
Namespaces & Lists
</router-link>
</li>
<li>
@ -34,7 +34,7 @@
<span class="icon">
<icon icon="tags"/>
</span>
{{ $t('label.title') }}
Labels
</router-link>
</li>
<li>
@ -42,26 +42,26 @@
<span class="icon">
<icon icon="users"/>
</span>
{{ $t('team.title') }}
Teams
</router-link>
</li>
</ul>
</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)"
class="menu-label"
v-tooltip="getNamespaceTitle(n) + ' (' + n.lists.filter(l => !l.isArchived).length + ')'">
v-tooltip="n.title + ' (' + n.lists.filter(l => !l.isArchived).length + ')'">
<span class="name">
<span
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
v-if="n.hexColor !== ''">
</span>
{{ getNamespaceTitle(n) }} ({{ n.lists.filter(l => !l.isArchived).length }})
{{ n.title }} ({{ n.lists.filter(l => !l.isArchived).length }})
</span>
</span>
<a
@ -73,54 +73,25 @@
</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
v-bind="dragOptions"
:value="activeLists[nk]"
@input="(lists) => updateActiveLists(n, lists)"
:group="`namespace-${n.id}-lists`"
@start="() => drag = true"
@end="e => saveListPosition(e, nk)"
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"
v-if="l.hexColor !== ''">
</span>
<span class="list-menu-title">
{{ getListTitle(l) }}
{{ l.title }}
</span>
<span
:class="{'is-favorite': l.isFavorite}"
@ -133,55 +104,41 @@
<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">
{{ $t('misc.poweredBy') }}
</a>
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank">Powered by Vikunja</a>
</div>
</template>
<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) {
return 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 => {
@ -206,7 +163,7 @@ export default {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
.catch(e => this.$message.error(e))
.catch(e => this.error(e, this))
},
resize() {
// Hide the menu by default on mobile
@ -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.$message.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,40 +16,32 @@
@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">
<h1
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
</h1>
<div class="list-title" v-if="currentList.id">
<h1
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? 'Loading...' : currentList.title }}
</h1>
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
</template>
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
</div>
<div class="navbar-end">
<update/>
<a
@click="openQuickActions"
class="trigger-button pr-0"
@shortkey="openQuickActions"
v-shortkey="['ctrl', 'k']"
>
<icon icon="search"/>
</a>
<notifications/>
<div class="user">
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
<dropdown class="is-right" ref="usernameDropdown">
<template #trigger>
<img :src="userAvatar" alt="" class="avatar"/>
<dropdown class="is-right">
<template v-slot:trigger>
<x-button
type="secondary"
:shadow="false">
@ -61,32 +53,27 @@
</template>
<router-link :to="{name: 'user.settings'}" class="dropdown-item">
{{ $t('user.settings.title') }}
Settings
</router-link>
<a
:href="imprintUrl"
class="dropdown-item"
target="_blank"
rel="noreferrer noopener nofollow"
v-if="imprintUrl">
{{ $t('navigation.imprint') }}
Imprint
</a>
<a
:href="privacyPolicyUrl"
class="dropdown-item"
target="_blank"
rel="noreferrer noopener nofollow"
v-if="privacyPolicyUrl">
{{ $t('navigation.privacy') }}
Privacy policy
</a>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">
{{ $t('keyboardShortcuts.title') }}
Keyboard Shortcuts
</a>
<router-link :to="{name: 'about'}" class="dropdown-item">
{{ $t('about.title') }}
</router-link>
<a @click="logout()" class="dropdown-item">
{{ $t('user.auth.logout') }}
Logout
</a>
</dropdown>
</div>
@ -96,12 +83,12 @@
<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 {CURRENT_LIST} from '@/store/mutation-types'
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',
@ -121,24 +108,11 @@ export default {
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
canWriteCurrentList: state => state.currentList.maxRight > Rights.READ,
}),
mounted() {
this.$nextTick(() => {
if (typeof this.$refs.usernameDropdown === 'undefined' || typeof this.$refs.listTitle === 'undefined') {
return
}
const usernameWidth = this.$refs.usernameDropdown.$el.clientWidth
this.$refs.listTitle.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
},
methods: {
logout() {
this.$store.dispatch('auth/logout')
this.$router.push({name: 'user.login'})
},
openQuickActions() {
this.$store.commit(QUICK_ACTIONS_ACTIVE, true)
},
},
}
</script>

View File

@ -1,13 +1,15 @@
<template>
<div class="update-notification" v-if="updateAvailable">
<p>{{ $t('update.available') }}</p>
<p>There is an update for Vikunja available!</p>
<x-button @click="refreshApp()" :shadow="false">
{{ $t('update.do') }}
Update Now
</x-button>
</div>
</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

@ -9,9 +9,9 @@
'is-text is-inverted has-no-shadow underline-none':
type === 'tertary',
}"
:disabled="disabled || null"
:disabled="disabled"
@click="click"
:href="href !== '' ? href : null"
:href="href !== '' ? href : false"
>
<icon :icon="icon" v-if="showIconOnly"/>
<span class="icon is-small" v-else-if="icon !== ''">
@ -55,7 +55,7 @@ export default {
computed: {
showIconOnly() {
return this.icon !== '' && typeof this.$slots.default === 'undefined'
},
}
},
methods: {
click(e) {

View File

@ -19,13 +19,14 @@
:class="{'is-empty': empty}"
/>
<x-button @click="reset" class="is-small ml-2" :shadow="false" type="secondary">
{{ $t('input.resetColor') }}
Reset Color
</x-button>
</div>
</template>
<script>
import verte from 'verte'
import 'verte/dist/verte.css'
export default {
name: 'colorPicker',
@ -48,16 +49,16 @@ export default {
},
},
watch: {
value: {
handler(value) {
this.color = value
},
immediate: true,
value(newVal) {
this.color = newVal
},
color() {
this.update()
},
},
mounted() {
this.color = this.value
},
computed: {
empty() {
return this.color === '#000000' || this.color === ''
@ -90,8 +91,6 @@ export default {
</script>
<style lang="scss">
@import 'verte/dist/verte.css';
.verte.is-empty {
.verte__icon {
opacity: 0;

View File

@ -18,7 +18,7 @@
</span>
<span class="text">
<span>
{{ $t('input.datepicker.today') }}
Today
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('today') }}
@ -31,7 +31,7 @@
</span>
<span class="text">
<span>
{{ $t('input.datepicker.tomorrow') }}
Tomorrow
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('tomorrow') }}
@ -44,7 +44,7 @@
</span>
<span class="text">
<span>
{{ $t('input.datepicker.nextMonday') }}
Next Monday
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('nextMonday') }}
@ -57,7 +57,7 @@
</span>
<span class="text">
<span>
{{ $t('input.datepicker.thisWeekend') }}
This Weekend
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('thisWeekend') }}
@ -70,7 +70,7 @@
</span>
<span class="text">
<span>
{{ $t('input.datepicker.laterThisWeek') }}
Later This Week
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('laterThisWeek') }}
@ -83,7 +83,7 @@
</span>
<span class="text">
<span>
{{ $t('input.datepicker.nextWeek') }}
Next Week
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('nextWeek') }}
@ -102,7 +102,7 @@
:shadow="false"
@click="close"
>
{{ $t('misc.confirm') }}
Confirm
</x-button>
</div>
</transition>
@ -127,6 +127,14 @@ export default {
show: false,
changed: false,
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
},
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
flatPickrDate: null,
@ -137,50 +145,33 @@ 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')
},
default: 'Choose a date'
},
disabled: {
type: Boolean,
default: false,
},
}
},
mounted() {
this.setDateValue(this.value)
document.addEventListener('click', this.hideDatePopup)
},
beforeDestroy() {
document.removeEventListener('click', this.hideDatePopup)
},
watch: {
value: {
handler: 'setDateValue',
immediate: true,
value(newVal) {
this.setDateValue(newVal)
},
flatPickrDate(newVal) {
this.date = createDateFromString(newVal)
this.updateData()
},
},
computed: {
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
},
methods: {
setDateValue(newVal) {
if(newVal === null) {

View File

@ -1,7 +1,26 @@
<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"
>
Done
</x-button>
</div>
<div class="clear"></div>
<vue-easymde
:configs="config"
@change="bubble"
@ -13,34 +32,22 @@
<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>
<a @click="toggleEdit">Edit</a>.
</p>
<ul class="actions" v-if="bottomActions.length > 0">
<template v-if="isEditEnabled && !showPreviewText && showSave">
<ul class="actions">
<template v-if="hasEditBottom">
<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-if="!isEditActive" @click="toggleEdit">Edit</a>
<a v-else @click="toggleEdit">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="isEditEnabled && 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,11 +56,9 @@ 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'
import {findCheckboxesInText} from '../../helpers/checklistFromText'
export default {
name: 'editor',
@ -89,6 +94,10 @@ export default {
isEditEnabled: {
default: true,
},
hasEditBottom: {
type: Boolean,
default: false,
},
bottomActions: {
default: () => [],
},
@ -96,15 +105,6 @@ export default {
type: String,
default: () => '',
},
showSave: {
type: Boolean,
default: false,
},
},
computed: {
showPreviewText() {
return this.isPreviewActive && this.text === '' && this.emptyText !== ''
},
},
data() {
return {
@ -115,7 +115,6 @@ export default {
preview: '',
attachmentService: null,
loadedAttachments: {},
config: {
autoDownloadFontAwesome: false,
@ -128,112 +127,112 @@ export default {
{
name: 'heading-1',
action: EasyMDE.toggleHeading1,
title: this.$t('input.editor.heading1'),
title: 'Heading 1',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-2',
action: EasyMDE.toggleHeading2,
title: this.$t('input.editor.heading2'),
title: 'Heading 2',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-3',
action: EasyMDE.toggleHeading3,
title: this.$t('input.editor.heading3'),
title: 'Heading 3',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-smaller',
action: EasyMDE.toggleHeadingSmaller,
title: this.$t('input.editor.headingSmaller'),
title: 'Heading Smaller',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-bigger',
action: EasyMDE.toggleHeadingBigger,
title: this.$t('input.editor.headingBigger'),
title: 'Heading Bigger',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
'|',
{
name: 'bold',
action: EasyMDE.toggleBold,
title: this.$t('input.editor.bold'),
title: 'Bold',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 3H6.5H15.25C18.15 3 20.5 5.36 20.5 8.25C20.5 9.8 19.81 11.19 18.73 12.15C20.37 13.04 21.5 14.76 21.5 16.75C21.5 19.64 19.15 22 16.25 22H6.5H3.5C2.95 22 2.5 21.55 2.5 21C2.5 20.45 2.95 20 3.5 20H5.5V5H3.5C2.95 5 2.5 4.55 2.5 4C2.5 3.45 2.95 3 3.5 3ZM7.5 20H16.25C18.04 20 19.5 18.54 19.5 16.75C19.5 14.96 18.04 13.5 16.25 13.5H7.5V20ZM7.5 11.5H15.25C17.04 11.5 18.5 10.04 18.5 8.25C18.5 6.46 17.04 5 15.25 5H7.5V11.5Z"/></svg>',
},
{
name: 'italic',
action: EasyMDE.toggleItalic,
title: this.$t('input.editor.italic'),
title: 'Italic',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M14.0967 4.2H17.0001C17.3301 4.2 17.6001 3.93 17.6001 3.6C17.6001 3.27 17.3301 3 17.0001 3H10.2001C9.8701 3 9.6001 3.27 9.6001 3.6C9.6001 3.93 9.8701 4.2 10.2001 4.2H12.8748L9.90335 19.8H6.9999C6.6699 19.8 6.3999 20.07 6.3999 20.4C6.3999 20.73 6.6699 21 6.9999 21H13.7999C14.1299 21 14.3999 20.73 14.3999 20.4C14.3999 20.07 14.1299 19.8 13.7999 19.8H11.1253L14.0967 4.2Z"/></svg>',
},
{
name: 'strikethrough',
action: EasyMDE.toggleStrikethrough,
title: this.$t('input.editor.strikethrough'),
title: 'Strikethrough',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.25 7.17005C18.25 7.50005 17.98 7.77005 17.65 7.77005C17.32 7.77005 17.05 7.50005 17.05 7.17005V5.96005C15.97 5.12005 14.17 4.56005 12.79 4.31005C11.1 4.00005 9.51 4.30005 8.41 5.12005C7.2 6.03005 6.67 7.67005 7.19 8.88005C7.56 9.73005 8.37 10.31 8.98 10.64C9.57215 10.9644 10.1961 11.2013 10.8465 11.3999H20.4C20.73 11.3999 21 11.6699 21 11.9999C21 12.3299 20.73 12.5999 20.4 12.5999H15.3012C16.6583 13.0929 17.5255 13.7765 17.95 14.69C18.73 16.36 17.74 18.33 16.36 19.41C15.05 20.4401 13.35 21 11.54 21H11.16C9.78 20.9401 8.34 20.5301 6.95 19.85V20.3601C6.95 20.6901 6.68 20.96 6.35 20.96C6.02 20.96 5.75 20.6901 5.75 20.3601V17.36C5.75 17.03 6.02 16.76 6.35 16.76C6.68 16.76 6.95 17.03 6.95 17.36V18.5C8.35 19.2801 9.81 19.74 11.21 19.8C12.86 19.89 14.46 19.39 15.62 18.48C16.6 17.71 17.37 16.3 16.86 15.21C16.55 14.54 15.8 14.0201 14.58 13.63C13.9711 13.4331 13.3222 13.2762 12.6906 13.1235C12.6168 13.1056 12.5432 13.0878 12.47 13.07C12.4313 13.0607 12.3925 13.0514 12.3537 13.0421C11.7861 12.9055 11.2108 12.767 10.6413 12.5999H3.6C3.27 12.5999 3 12.3299 3 11.9999C3 11.6699 3.27 11.3999 3.6 11.3999H7.90288C7.04984 10.8343 6.42752 10.1363 6.09 9.36005C5.34 7.63005 6.03 5.40005 7.69 4.16005C9.05 3.15005 10.99 2.77005 13 3.13005C13.64 3.25005 15.53 3.66005 17.05 4.53005V4.17005C17.05 3.84005 17.32 3.57005 17.65 3.57005C17.98 3.57005 18.25 3.84005 18.25 4.17005V7.17005Z"/></svg>',
},
{
name: 'code',
action: EasyMDE.toggleCodeBlock,
title: this.$t('input.editor.code'),
title: 'Code',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.57 20.9601C8.64 20.9901 8.71 21.0001 8.78 21.0001C9.02 21.0001 9.24 20.8501 9.34 20.6101L15.79 3.81005C15.9 3.50005 15.75 3.15005 15.44 3.03005C15.14 2.92005 14.79 3.07005 14.67 3.38005L8.22 20.1801C8.11 20.4901 8.26 20.8401 8.57 20.9601ZM7.00007 18.0001C6.85007 18.0001 6.69007 17.9401 6.58007 17.8201L1.18007 12.4201C0.950068 12.1901 0.950068 11.8101 1.18007 11.5701L6.58007 6.17006C6.81007 5.94006 7.19007 5.94006 7.43007 6.17006C7.66007 6.40006 7.66007 6.78006 7.43007 7.02006L2.45007 12.0001L7.43007 16.9801C7.66007 17.2101 7.66007 17.5901 7.43007 17.8301C7.31007 17.9401 7.15007 18.0001 7.00007 18.0001ZM17 18.0001C16.85 18.0001 16.69 17.9401 16.58 17.8201C16.35 17.5901 16.35 17.2101 16.58 16.9701L21.55 12.0001L16.57 7.02006C16.34 6.79006 16.34 6.41006 16.57 6.17006C16.81 5.94006 17.19 5.94006 17.42 6.17006L22.82 11.5701C23.05 11.8001 23.05 12.1801 22.82 12.4201L17.42 17.8201C17.31 17.9401 17.15 18.0001 17 18.0001Z"/></svg>',
},
{
name: 'quote',
action: EasyMDE.toggleBlockquote,
title: this.$t('input.editor.quote'),
title: 'Quote',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.373 5.16357H5.62695C4.79102 5.16357 4.11133 5.84326 4.11133 6.6792V16.2095C4.11133 17.0464 4.79102 17.7261 5.62695 17.7261H6.8877V21.1245C6.8877 21.3667 7.0332 21.5854 7.25684 21.6782C7.33203 21.7095 7.41016 21.7241 7.4873 21.7241C7.64258 21.7241 7.7959 21.6636 7.91113 21.5493L11.748 17.7261H19.373C20.209 17.7261 20.8887 17.0464 20.8887 16.2095V6.6792C20.8887 5.84326 20.209 5.16357 19.373 5.16357ZM19.6895 16.2095C19.6895 16.3843 19.5469 16.5269 19.373 16.5269H11.5C11.3408 16.5269 11.1895 16.5894 11.0762 16.7017L8.08691 19.6802V17.1265C8.08691 16.7954 7.81836 16.5269 7.4873 16.5269H5.62695C5.45312 16.5269 5.31055 16.3843 5.31055 16.2095V6.6792C5.31055 6.50537 5.45312 6.36279 5.62695 6.36279H19.373C19.5469 6.36279 19.6895 6.50537 19.6895 6.6792V16.2095ZM10.3431 8.45264C9.46326 8.45264 8.75 9.16589 8.75 10.0458C8.75 10.9257 9.46326 11.639 10.3431 11.639C10.4775 11.639 10.6058 11.6173 10.7305 11.5861V11.6195C10.7305 12.1322 10.3135 12.5492 9.75586 12.5492C9.4248 12.5492 9.17871 12.8177 9.17871 13.1488C9.17871 13.4799 9.46973 13.7484 9.80078 13.7484C10.9746 13.7484 11.9297 12.7933 11.9297 11.6195V10.1176L11.9294 10.1165L11.9292 10.1155C11.9297 10.1049 11.9312 10.0946 11.9326 10.0843L11.9326 10.0843C11.9345 10.0716 11.9363 10.059 11.9363 10.0458C11.9363 9.16589 11.223 8.45264 10.3431 8.45264ZM13.0637 10.0458C13.0637 9.16589 13.7771 8.45264 14.657 8.45264C15.5369 8.45264 16.2501 9.16589 16.2501 10.0458C16.2501 10.0584 16.2484 10.0706 16.2466 10.0828C16.2452 10.0929 16.2437 10.103 16.2433 10.1134C16.2433 10.1149 16.2441 10.1161 16.2441 10.1176V11.6195C16.2441 12.7933 15.2891 13.7484 14.1152 13.7484C13.7842 13.7484 13.4922 13.4799 13.4922 13.1488C13.4922 12.8177 13.7383 12.5492 14.0693 12.5492C14.6279 12.5492 15.0449 12.1322 15.0449 11.6195V11.5858C14.9202 11.6173 14.7915 11.639 14.657 11.639C13.7771 11.639 13.0637 10.9257 13.0637 10.0458Z"/></svg>',
},
{
name: 'unordered-list',
action: EasyMDE.toggleUnorderedList,
title: this.$t('input.editor.unorderedList'),
title: 'Unordered List',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.5281 3.7002H3.5281C3.1981 3.7002 2.9281 3.9702 2.9281 4.3002V7.3002C2.9281 7.6302 3.1981 7.9002 3.5281 7.9002H6.5281C6.8581 7.9002 7.1281 7.6302 7.1281 7.3002V4.3002C7.1281 3.9702 6.8581 3.7002 6.5281 3.7002ZM5.9281 6.7002H4.1281V4.9002H5.9281V6.7002ZM3.5281 9.90015H6.5281C6.8581 9.90015 7.1281 10.1701 7.1281 10.5001V13.5001C7.1281 13.8301 6.8581 14.1001 6.5281 14.1001H3.5281C3.1981 14.1001 2.9281 13.8301 2.9281 13.5001V10.5001C2.9281 10.1701 3.1981 9.90015 3.5281 9.90015ZM4.1281 12.9001H5.9281V11.1001H4.1281V12.9001ZM3.5281 16.1001H6.5281C6.8581 16.1001 7.1281 16.3701 7.1281 16.7001V19.7001C7.1281 20.0301 6.8581 20.3001 6.5281 20.3001H3.5281C3.1981 20.3001 2.9281 20.0301 2.9281 19.7001V16.7001C2.9281 16.3701 3.1981 16.1001 3.5281 16.1001ZM4.1281 19.1001H5.9281V17.3001H4.1281V19.1001ZM9.72817 6.4002H20.7282C21.0582 6.4002 21.3282 6.1302 21.3282 5.8002C21.3282 5.4702 21.0582 5.2002 20.7282 5.2002H9.72817C9.39817 5.2002 9.12817 5.4702 9.12817 5.8002C9.12817 6.1302 9.39817 6.4002 9.72817 6.4002ZM9.72817 11.4001H20.7282C21.0582 11.4001 21.3282 11.6701 21.3282 12.0001C21.3282 12.3301 21.0582 12.6001 20.7282 12.6001H9.72817C9.39817 12.6001 9.12817 12.3301 9.12817 12.0001C9.12817 11.6701 9.39817 11.4001 9.72817 11.4001ZM9.72817 17.6001H20.7282C21.0582 17.6001 21.3282 17.8701 21.3282 18.2001C21.3282 18.5301 21.0582 18.8001 20.7282 18.8001H9.72817C9.39817 18.8001 9.12817 18.5301 9.12817 18.2001C9.12817 17.8701 9.39817 17.6001 9.72817 17.6001Z"/></svg>',
},
{
name: 'ordered-list',
action: EasyMDE.toggleOrderedList,
title: this.$t('input.editor.orderedList'),
title: 'Ordered List',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4.19494 8.29994H5.99494C6.26494 8.29994 6.49494 8.07995 6.49494 7.79994C6.49494 7.51995 6.27494 7.29994 5.99494 7.29994H5.59494V3.79994C5.59494 3.62994 5.50494 3.46994 5.36494 3.37994C5.22494 3.28994 5.04494 3.26994 4.89494 3.33994L3.89494 3.76994C3.64494 3.87994 3.52494 4.17994 3.63494 4.42994C3.74494 4.67994 4.03494 4.79994 4.29494 4.68994L4.59494 4.55994V7.29994H4.19494C3.91494 7.29994 3.69494 7.51995 3.69494 7.79994C3.69494 8.07995 3.91494 8.29994 4.19494 8.29994ZM20.195 6.39995H9.19497C8.86497 6.39995 8.59497 6.12995 8.59497 5.79995C8.59497 5.46995 8.86497 5.19995 9.19497 5.19995H20.195C20.525 5.19995 20.795 5.46995 20.795 5.79995C20.795 6.12995 20.525 6.39995 20.195 6.39995ZM3.78486 14.36H6.37486C6.65486 14.36 6.87486 14.14 6.87486 13.86C6.87486 13.58 6.65486 13.36 6.37486 13.36H4.88486C5.00486 13.23 5.12486 13.09 5.23486 12.95C5.26626 12.9151 5.29645 12.8802 5.32626 12.8458L5.32629 12.8457C5.38192 12.7814 5.43627 12.7186 5.49486 12.66C5.73486 12.4 5.98486 12.12 6.17486 11.79C6.47486 11.25 6.41486 10.63 6.01486 10.17C5.57486 9.66 4.86486 9.5 4.24486 9.74C3.74486 9.95 3.39486 10.35 3.22486 10.91C3.14486 11.18 3.29486 11.46 3.56486 11.54C3.82486 11.61 4.10486 11.46 4.18486 11.2C4.29486 10.85 4.48486 10.73 4.62486 10.67C4.88486 10.57 5.13486 10.68 5.26486 10.82C5.38486 10.96 5.40486 11.12 5.30486 11.29C5.17595 11.5202 4.99618 11.7165 4.80458 11.9257L4.75486 11.98C4.67298 12.0801 4.58283 12.1801 4.49946 12.2727L4.49945 12.2727L4.47486 12.3C4.12486 12.72 3.76486 13.13 3.40486 13.53C3.27486 13.68 3.23486 13.9 3.32486 14.07C3.41486 14.24 3.58486 14.36 3.78486 14.36ZM3.68486 20.3699C4.04486 20.5899 4.46486 20.6999 4.87486 20.6999C5.13486 20.6999 5.38486 20.6499 5.61486 20.5499C6.31486 20.2799 6.73486 19.5599 6.60486 18.8799C6.53486 18.5499 6.35486 18.2899 6.12486 18.0899C6.32486 17.8999 6.45486 17.6499 6.50486 17.3799C6.57486 17.0099 6.49486 16.6299 6.27486 16.3099C5.85486 15.6899 5.07486 15.5199 4.10486 15.8299C3.83486 15.9199 3.69486 16.1999 3.77486 16.4599C3.86486 16.7299 4.14486 16.8699 4.40486 16.7899C4.70486 16.6999 5.24486 16.5799 5.45486 16.8899C5.51486 16.9899 5.54486 17.0999 5.52486 17.1999C5.51486 17.2699 5.47486 17.3599 5.36486 17.4299C5.26486 17.4999 5.12486 17.5399 4.95486 17.5799L4.77486 17.6299C4.54486 17.6999 4.40486 17.9099 4.41486 18.1499C4.42486 18.3899 4.61486 18.5799 4.84486 18.6099C5.20486 18.6599 5.58486 18.8299 5.63486 19.0799C5.67486 19.2999 5.46486 19.5499 5.25486 19.6299C4.94486 19.7599 4.52486 19.7099 4.21486 19.5199C3.97486 19.3699 3.67486 19.4399 3.52486 19.6799C3.37486 19.9199 3.44486 20.2299 3.68486 20.3699ZM20.195 18.7999H9.19497C8.86497 18.7999 8.59497 18.5299 8.59497 18.1999C8.59497 17.8699 8.86497 17.5999 9.19497 17.5999H20.195C20.525 17.5999 20.795 17.8699 20.795 18.1999C20.795 18.5299 20.525 18.7999 20.195 18.7999ZM9.19497 12.5999H20.195C20.525 12.5999 20.795 12.3299 20.795 11.9999C20.795 11.6699 20.525 11.3999 20.195 11.3999H9.19497C8.86497 11.3999 8.59497 11.6699 8.59497 11.9999C8.59497 12.3299 8.86497 12.5999 9.19497 12.5999Z"/></svg>',
},
'|',
{
name: 'clean-block',
action: EasyMDE.cleanBlock,
title: this.$t('input.editor.cleanBlock'),
title: 'Clean Block',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9.25989 6.18384H20.4513C20.7823 6.18384 21.0509 6.45239 21.0509 6.78345V17.9749C21.0509 18.3059 20.7823 18.5745 20.4513 18.5745H9.25989C9.0929 18.5745 8.93469 18.5061 8.82043 18.384L3.6095 12.7883C3.39563 12.5579 3.39563 12.2004 3.6095 11.97L8.82043 6.37427C8.93469 6.2522 9.0929 6.18384 9.25989 6.18384ZM9.52063 17.3752H19.8517V7.38306H9.52063L4.86926 12.3792L9.52063 17.3752ZM12.7755 15.0686C12.6222 15.0686 12.4679 15.01 12.3517 14.8928C12.1173 14.6584 12.1173 14.2786 12.3517 14.0452L14.0503 12.3469L12.3517 10.6487C12.1173 10.4153 12.1173 10.0354 12.3517 9.80103C12.5841 9.56665 12.965 9.56665 13.1993 9.80103L14.8981 11.4994L16.5968 9.80103C16.8312 9.56665 17.212 9.56665 17.4445 9.80103C17.6788 10.0354 17.6788 10.4153 17.4445 10.6487L15.7458 12.3469L17.4445 14.0452C17.6788 14.2786 17.6788 14.6584 17.4445 14.8928C17.3282 15.01 17.174 15.0686 17.0206 15.0686C16.8673 15.0686 16.714 15.01 16.5968 14.8928L14.8981 13.1945L13.1993 14.8928C13.0822 15.01 12.9288 15.0686 12.7755 15.0686Z"/></svg>',
},
{
name: 'link',
action: EasyMDE.drawLink,
title: this.$t('input.editor.link'),
title: 'Link',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11.4399 15.3452C11.4999 15.3652 11.5699 15.3752 11.6299 15.3752C11.8799 15.3752 12.1199 15.2152 12.1999 14.9652C12.2999 14.6452 12.1299 14.3052 11.8199 14.2052C11.3499 14.0452 10.9299 13.7852 10.5699 13.4152C10.1999 13.0552 9.9399 12.6452 9.7799 12.1552C9.6599 11.8252 9.5999 11.4652 9.5999 11.0952C9.5999 10.2152 9.9399 9.38518 10.5699 8.75518L15.1599 4.15518C16.4499 2.87518 18.5399 2.87518 19.8299 4.15518C20.4499 4.78518 20.7899 5.61518 20.7899 6.49518C20.7899 7.37518 20.4499 8.20518 19.8299 8.82518L16.7399 11.9052C16.5099 12.1452 16.5099 12.5252 16.7399 12.7552C16.9799 12.9852 17.3599 12.9852 17.5899 12.7552L20.6799 9.67518C21.5299 8.83518 21.9999 7.69518 21.9999 6.49518C21.9999 5.29518 21.5299 4.16518 20.6899 3.30518C18.9299 1.55518 16.0799 1.55518 14.3199 3.30518L9.7299 7.90518C8.8699 8.75518 8.3999 9.88518 8.3999 11.0952C8.3999 11.6152 8.4899 12.1152 8.6499 12.5552C8.8599 13.1952 9.2399 13.7952 9.7199 14.2652C10.1999 14.7552 10.7999 15.1352 11.4399 15.3452ZM3.32 20.6851C4.2 21.5551 5.35 21.9951 6.5 21.9951C7.65 21.9951 8.81 21.5551 9.69 20.7051L14.28 16.1051C15.14 15.2551 15.61 14.1251 15.61 12.9151C15.61 12.4551 15.54 11.9951 15.4 11.5551C15.17 10.8651 14.8 10.2551 14.28 9.73509C13.76 9.21509 13.15 8.84509 12.46 8.61509C12.14 8.51509 11.8 8.68509 11.7 8.99509C11.6 9.30509 11.77 9.64509 12.1 9.75509C12.61 9.91509 13.06 10.1951 13.44 10.5751C13.82 10.9551 14.09 11.4051 14.26 11.9151C14.36 12.2351 14.41 12.5651 14.41 12.9051C14.41 13.7951 14.06 14.6251 13.43 15.2451L8.84 19.8451C7.55 21.1251 5.46 21.1251 4.17 19.8451C3.55 19.2151 3.21 18.3951 3.21 17.5051C3.21 16.6151 3.55 15.7851 4.17 15.1651L7.35 11.9851C7.58 11.7451 7.59 11.3651 7.35 11.1351C7.11 10.9051 6.73 10.9051 6.5 11.1351L3.32 14.3151C2.47 15.1551 2 16.2851 2 17.4951C2 18.7051 2.47 19.8351 3.32 20.6851Z"/></svg>',
},
{
name: 'image',
action: EasyMDE.drawImage,
title: this.$t('input.editor.image'),
title: 'Image',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4C2.89543 4 2 4.89543 2 6V16V17.5152V18C2 19.1046 2.89543 20 4 20H20C21.0528 20 21.9156 19.1866 21.9942 18.1539L22 18.1632V18V16V6C22 4.89543 21.1046 4 20 4H4ZM3.2 17.7V16.5642L6.78192 13.7254C6.8616 13.6622 6.97597 13.6689 7.04776 13.7409L10.3126 17.0146C10.7026 17.4056 11.3357 17.4065 11.7268 17.0165C11.7606 16.9827 11.792 16.9465 11.8207 16.9083L16.736 10.352C16.8023 10.2636 16.9277 10.2457 17.016 10.312C17.0355 10.3265 17.0521 10.3445 17.0651 10.365L20.8 16.2669V17.7C20.8 18.3075 20.3075 18.8 19.7 18.8H4.3C3.69249 18.8 3.2 18.3075 3.2 17.7ZM17.3865 8.61836L20.8 14.08V6.3C20.8 5.69249 20.3075 5.2 19.7 5.2H4.3C3.69249 5.2 3.2 5.69249 3.2 6.3V15.04L6.65054 12.2796C6.84949 12.1204 7.13629 12.1363 7.31645 12.3164L10.8369 15.8369C10.915 15.915 11.0417 15.915 11.1198 15.8369C11.1265 15.8302 16.5625 8.58336 16.5625 8.58336C16.7282 8.36245 17.0416 8.31768 17.2625 8.48336C17.3118 8.52034 17.3538 8.56611 17.3865 8.61836ZM8 8.5C8 9.32843 7.32843 10 6.5 10C5.67157 10 5 9.32843 5 8.5C5 7.67157 5.67157 7 6.5 7C7.32843 7 8 7.67157 8 8.5Z"/></svg>',
},
{
name: 'table',
action: EasyMDE.drawTable,
title: this.$t('input.editor.table'),
title: 'Table',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.18524 3.08496H19.4152C20.6752 3.08496 21.7152 4.11496 21.7152 5.38496V18.615C21.7152 19.885 20.6852 20.915 19.4152 20.915H6.18524C4.91524 20.915 3.88525 19.885 3.88525 18.615V5.38496C3.88525 4.11496 4.91524 3.08496 6.18524 3.08496ZM19.4052 19.705C20.0152 19.705 20.5052 19.215 20.5052 18.605H20.5153V5.38496C20.5153 4.77496 20.0252 4.28496 19.4152 4.28496H6.18524C5.57524 4.28496 5.08521 4.77496 5.08521 5.38496V18.605C5.08521 19.215 5.57524 19.705 6.18524 19.705H19.4052ZM17.4453 9.15503H8.15527C7.82527 9.15503 7.5553 9.42503 7.5553 9.75503C7.5553 10.085 7.82527 10.355 8.15527 10.355H17.4453C17.7753 10.355 18.0453 10.085 18.0453 9.75503C18.0453 9.42503 17.7753 9.15503 17.4453 9.15503ZM17.4453 13.635H8.15527C7.82527 13.635 7.5553 13.905 7.5553 14.235C7.5553 14.565 7.82527 14.835 8.15527 14.835H17.4453C17.7753 14.835 18.0453 14.565 18.0453 14.235C18.0453 13.905 17.7753 13.635 17.4453 13.635Z"/></svg>',
},
{
name: 'horizontal-rule',
action: EasyMDE.drawHorizontalRule,
title: this.$t('input.editor.horizontalRule'),
title: 'Horizontal Rule',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M21 13H3C2.45 13 2 12.55 2 12C2 11.45 2.45 11 3 11H21C21.55 11 22 11.45 22 12C22 12.55 21.55 13 21 13Z"/></svg>',
},
'|',
{
name: 'side-by-side',
action: EasyMDE.toggleSideBySide,
title: this.$t('input.editor.sideBySide'),
title: 'Side By Side',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.4787 14.58C18.3587 14.69 18.2987 14.85 18.2987 15C18.2987 15.15 18.3587 15.31 18.4787 15.42C18.7187 15.65 19.0987 15.65 19.3287 15.42L22.3287 12.42C22.5587 12.18 22.5587 11.8 22.3287 11.57L19.3287 8.56996C19.0887 8.33996 18.7087 8.33996 18.4787 8.56996C18.2487 8.80996 18.2487 9.18996 18.4787 9.41996L20.451 11.3999L14.4487 11.3999L14.4487 4.6C14.4487 4.27 14.1787 4 13.8487 4C13.5187 4 13.2487 4.27 13.2487 4.6L13.2487 19.4C13.2487 19.73 13.5187 20 13.8487 20C14.1787 20 14.4487 19.73 14.4487 19.4L14.4487 12.5999L20.4511 12.5999L18.4787 14.58ZM9.54878 19.4L9.54878 12.5999L3.5486 12.5999L5.52867 14.58C5.75867 14.81 5.75867 15.19 5.52867 15.43C5.29867 15.66 4.91867 15.66 4.67867 15.43L1.67867 12.43C1.63274 12.384 1.5956 12.3323 1.56725 12.2774C1.53058 12.2077 1.50724 12.1299 1.50068 12.0477C1.49934 12.0317 1.49867 12.0158 1.49867 12C1.49867 11.9841 1.49933 11.9682 1.50067 11.9522C1.51454 11.778 1.60365 11.6242 1.73526 11.5234L4.67867 8.57997C4.90867 8.34997 5.28867 8.34997 5.52867 8.57997C5.75867 8.80997 5.75867 9.18997 5.52867 9.42997L3.55107 11.3999L9.54878 11.3999L9.54878 4.6C9.54878 4.27 9.81878 4 10.1488 4C10.4788 4 10.7488 4.27 10.7488 4.6L10.7488 11.9999L10.7488 19.4C10.7488 19.73 10.4788 20 10.1488 20C9.81878 20 9.54878 19.73 9.54878 19.4Z"/></svg>',
},
{
@ -241,7 +240,7 @@ export default {
action: () => {
window.open('https://www.markdownguide.org/basic-syntax/', '_blank')
},
title: this.$t('input.editor.guide'),
title: 'Guide',
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.4999 2.3999H6.4999C5.0699 2.3999 3.8999 3.5699 3.8999 4.9999V18.9999C3.8999 20.4299 5.0699 21.5999 6.4999 21.5999H19.4999C19.8299 21.5999 20.0999 21.3299 20.0999 20.9999V16.9999V2.9999C20.0999 2.6699 19.8299 2.3999 19.4999 2.3999ZM5.0999 4.9999V16.8118C5.50468 16.5513 5.98546 16.3999 6.4999 16.3999H18.8999V3.5999H6.4999C5.7299 3.5999 5.0999 4.2299 5.0999 4.9999ZM6.4999 17.5999H18.8999V20.3999H6.4999C5.7299 20.3999 5.0999 19.7699 5.0999 18.9999C5.0999 18.2299 5.7299 17.5999 6.4999 17.5999ZM8.4999 8.5999H15.4999C15.8299 8.5999 16.0999 8.3299 16.0999 7.9999C16.0999 7.6699 15.8299 7.3999 15.4999 7.3999H8.4999C8.1699 7.3999 7.8999 7.6699 7.8999 7.9999C7.8999 8.3299 8.1699 8.5999 8.4999 8.5999ZM15.4999 11.3999H8.4999C8.1699 11.3999 7.8999 11.6699 7.8999 11.9999C7.8999 12.3299 8.1699 12.5999 8.4999 12.5999H15.4999C15.8299 12.5999 16.0999 12.3299 16.0999 11.9999C16.0999 11.6699 15.8299 11.3999 15.4999 11.3999Z"/></svg>',
},
],
@ -283,7 +282,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
}
@ -304,7 +303,34 @@ export default {
return str.substr(0, index) + replacement + str.substr(index + replacement.length)
},
findNthIndex(str, n) {
const checkboxes = findCheckboxesInText(str)
const searchChecked = '* [x] '
const searchUnchecked = '* [ ] '
let inChecked, inUnchecked, startIndex = 0
// We're building an array with all checkboxes, checked or unchecked.
// I've found this to be the best way to always get the results I need.
// The difficulty without an index is that we need to get all checkboxes, checked and unchecked
// and calculate our index based off that to compare it and find the checkbox we need.
let checkboxes = []
// Searching in two different loops for each search term since that is way easier and more predicatble
// More "intelligent" solutions sometimes don't have all values or duplicates.
// Because we're sorting and removing duplicates of them, we can safely put everything in one giant array.
while ((inChecked = str.indexOf(searchChecked, startIndex)) > -1) {
checkboxes.push(inChecked)
startIndex = startIndex + searchChecked.length
}
startIndex = 0
while ((inUnchecked = str.indexOf(searchUnchecked, startIndex)) > -1) {
checkboxes.push(inUnchecked)
startIndex = startIndex + searchUnchecked.length
}
checkboxes.sort((a, b) => a - b)
checkboxes = checkboxes.filter((v, i, s) => s.indexOf(v) === i && v > -1)
return checkboxes[n]
},
renderPreview() {
@ -337,16 +363,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
return hljs.highlight(validLanguage, code).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.
@ -363,13 +390,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) {
@ -379,7 +399,6 @@ export default {
this.attachmentService.getBlobUrl(attachment)
.then(url => {
img.src = url
this.loadedAttachments[cacheKey] = url
})
}
}
@ -389,7 +408,6 @@ export default {
for (const check of textCheckbox) {
check.removeEventListener('change', this.handleCheckboxClick)
check.addEventListener('change', this.handleCheckboxClick)
check.parentElement.classList.add('has-checkbox')
}
}
})
@ -406,12 +424,10 @@ export default {
}
console.debug(index, this.text.substr(index, 9))
const listPrefix = this.text.substr(index, 1)
if (checked) {
this.text = this.replaceAt(this.text, index, `${listPrefix} [x] `)
this.text = this.replaceAt(this.text, index, '* [x] ')
} else {
this.text = this.replaceAt(this.text, index, `${listPrefix} [ ] `)
this.text = this.replaceAt(this.text, index, '* [ ] ')
}
this.bubble()
this.renderPreview()
@ -432,27 +448,17 @@ export default {
</script>
<style lang="scss">
@import '../../../node_modules/highlight.js/scss/base16/equilibrium-gray-light';
@import '../../../node_modules/highlight.js/scss/atelier-heath-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;
}
&.has-checkbox {
margin-left: -2em;
list-style: none;
}
}
.preview.content ul li input[type="checkbox"] {
margin-right: .5rem;
}
}
@ -531,10 +537,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">
@ -26,7 +26,7 @@ export default {
data() {
return {
checked: false,
checkBoxId: 'fancycheckbox' + Math.random(),
checkBoxId: '',
}
},
props: {
@ -40,19 +40,21 @@ export default {
},
},
watch: {
value: {
handler(value) {
this.checked = value
},
immediate: true,
value(newVal) {
this.checked = newVal
},
},
mounted() {
this.checked = this.value
},
created() {
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

@ -26,15 +26,36 @@
@keyup="search"
@keyup.enter.exact.prevent="() => createOrSelectOnEnter()"
:placeholder="placeholder"
@keydown.down.exact.prevent="() => preSelect(0)"
@keydown.down.exact.prevent="() => preSelect(0, true)"
ref="searchInput"
@focus="handleFocus"
@focus="() => showSearchResults = true"
/>
</div>
</div>
<transition name="fade">
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<div class="search-results" v-if="searchResultsVisible">
<button
v-if="creatableAvailable"
class="is-fullwidth"
ref="result--1"
@keydown.up.prevent="() => preSelect(-2)"
@keydown.down.prevent="() => preSelect(0)"
@keyup.enter.prevent="create"
@click.prevent.stop="create"
>
<span>
<slot name="searchResult" :option="query">
<span class="search-result">
{{ query }}
</span>
</slot>
</span>
<span class="hint-text">
{{ createPlaceholder }}
</span>
</button>
<button
class="is-fullwidth"
v-for="(data, key) in filteredSearchResults"
@ -53,27 +74,6 @@
{{ selectPlaceholder }}
</span>
</button>
<button
v-if="creatableAvailable"
class="is-fullwidth"
:ref="`result-${filteredSearchResults.length}`"
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
@keyup.enter.prevent="create"
@click.prevent.stop="create"
>
<span>
<slot name="searchResult" :option="query">
<span class="search-result">
{{ query }}
</span>
</slot>
</span>
<span class="hint-text">
{{ createPlaceholder }}
</span>
</button>
</div>
</transition>
@ -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: {
@ -149,14 +149,14 @@ export default {
createPlaceholder: {
type: String,
default() {
return this.$t('input.multiselect.createPlaceholder')
return 'Create new'
},
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
return this.$t('input.multiselect.selectPlaceholder')
return 'Click or press enter to select'
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
@ -166,48 +166,21 @@ export default {
return false
},
},
// If true, displays the search results inline instead of using a dropdown.
inline: {
type: Boolean,
default() {
return false
},
},
// If true, shows search results when no query is specified.
showEmpty: {
type: Boolean,
default() {
return true
},
},
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
searchDelay: {
type: Number,
default() {
return 200
},
},
},
mounted() {
document.addEventListener('click', this.hideSearchResultsHandler)
this.setSelectedObject(this.value)
},
beforeDestroy() {
document.removeEventListener('click', this.hideSearchResultsHandler)
},
watch: {
value: {
handler(value) {
this.setSelectedObject(value)
},
immediate: true,
value(newVal) {
this.setSelectedObject(newVal)
},
},
computed: {
searchResultsVisible() {
if (this.query === '' && !this.showEmpty) {
return false
}
return this.showSearchResults && (
(this.filteredSearchResults.length > 0) ||
(this.creatable && this.query !== '')
@ -224,7 +197,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))
}
@ -238,7 +211,7 @@ export default {
// Updating the query with a binding does not work on mobile for some reason,
// getting the value manual does.
this.query = this.$refs.searchInput.value
if (this.searchTimeout !== null) {
clearTimeout(this.searchTimeout)
this.searchTimeout = null
@ -252,7 +225,7 @@ export default {
this.localLoading = false
}, 100) // The duration of the loading timeout of the services
this.showSearchResults = true
}, this.searchDelay)
}, 200)
},
hideSearchResultsHandler(e) {
closeWhenClickedOutside(e, this.$refs.multiselectRoot, this.closeSearchResults)
@ -260,13 +233,6 @@ export default {
closeSearchResults() {
this.showSearchResults = false
},
handleFocus() {
// We need the timeout to avoid the hideSearchResultsHandler hiding the search results right after the input
// is focused. That would lead to flickering pre-loaded search results and hiding them right after showing.
setTimeout(() => {
this.showSearchResults = true
}, 10)
},
select(object) {
if (this.multiple) {
if (this.internalValue === null) {
@ -304,8 +270,13 @@ export default {
this.query = this.label !== '' ? object[this.label] : object
},
preSelect(index) {
if (index < 0) {
preSelect(index, lookForCreatable = false) {
if (index === 0 && this.creatable && lookForCreatable) {
index = -1
}
if (index < -1) {
this.$refs.searchInput.focus()
return
}

View File

@ -5,13 +5,13 @@
:to="{ name: `${listRoutePrefix}.settings.edit`, params: { listId: list.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
Edit
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.delete`, params: { listId: list.id } }"
icon="trash-alt"
>
{{ $t('misc.delete') }}
Delete
</dropdown-item>
</template>
<template v-else-if="list.isArchived">
@ -19,7 +19,7 @@
:to="{ name: `${listRoutePrefix}.settings.archive`, params: { listId: list.id } }"
icon="archive"
>
{{ $t('menu.unarchive') }}
Un-Archive
</dropdown-item>
</template>
<template v-else>
@ -27,32 +27,32 @@
:to="{ name: `${listRoutePrefix}.settings.edit`, params: { listId: list.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
Edit
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.background`, params: { listId: list.id } }"
v-if="backgroundsEnabled"
icon="image"
>
{{ $t('menu.setBackground') }}
Set background
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.share`, params: { listId: list.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
Share
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.duplicate`, params: { listId: list.id } }"
icon="paste"
>
{{ $t('menu.duplicate') }}
Duplicate
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.archive`, params: { listId: list.id } }"
icon="archive"
>
{{ $t('menu.archive') }}
Archive
</dropdown-item>
<task-subscription
class="dropdown-item has-no-shadow"
@ -67,7 +67,7 @@
icon="trash-alt"
class="has-text-danger"
>
{{ $t('menu.delete') }}
Delete
</dropdown-item>
</template>
</dropdown>
@ -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

@ -25,17 +25,15 @@ export default {
Filters,
},
mounted() {
this.params = this.value
document.addEventListener('click', this.hidePopup)
},
beforeDestroy() {
document.removeEventListener('click', this.hidePopup)
},
watch: {
value: {
handler(value) {
this.params = value
},
immediate: true,
value(newVal) {
this.$set(this, 'params', newVal)
},
visible() {
this.visibleInternal = !this.visibleInternal

View File

@ -1,30 +1,28 @@
<template>
<card class="filters has-overflow">
<fancycheckbox v-model="params.filter_include_nulls">
{{ $t('filters.attributes.includeNulls') }}
Include Tasks which don't have a value set
</fancycheckbox>
<fancycheckbox
v-model="filters.requireAllFilters"
@change="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
Require all filters to be true for a task to show up
</fancycheckbox>
<div class="field">
<label class="label">
{{ $t('filters.attributes.showDoneTasks') }}
</label>
<label class="label">Show Done Tasks</label>
<div class="control">
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
{{ $t('filters.attributes.showDoneTasks') }}
Show Done Tasks
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('misc.search') }}</label>
<label class="label">Search</label>
<div class="control">
<input
class="input"
:placeholder="$t('misc.search')"
placeholder="Search"
v-model="params.s"
@blur="change()"
@keyup.enter="change()"
@ -32,7 +30,7 @@
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.priority') }}</label>
<label class="label">Priority</label>
<div class="control single-value-control">
<priority-select
:disabled="!filters.usePriority"
@ -43,12 +41,12 @@
v-model="filters.usePriority"
@change="setPriority"
>
{{ $t('filters.attributes.enablePriority') }}
Enable Filter By Priority
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.percentDone') }}</label>
<label class="label">Percent Done</label>
<div class="control single-value-control">
<percent-done-select
v-model.number="filters.percentDone"
@ -59,65 +57,65 @@
v-model="filters.usePercentDone"
@change="setPercentDoneFilter"
>
{{ $t('filters.attributes.enablePercentDone') }}
Enable Filter By Percent Done
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<label class="label">Due Date</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setDueDateFilter"
class="input"
:placeholder="$t('filters.attributes.dueDateRange')"
placeholder="Due Date Range"
v-model="filters.dueDate"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<label class="label">Start Date</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setStartDateFilter"
class="input"
:placeholder="$t('filters.attributes.startDateRange')"
placeholder="Start Date Range"
v-model="filters.startDate"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<label class="label">End Date</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setEndDateFilter"
class="input"
:placeholder="$t('filters.attributes.endDateRange')"
placeholder="End Date Range"
v-model="filters.endDate"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<label class="label">Reminders</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setReminderFilter"
class="input"
:placeholder="$t('filters.attributes.reminderRange')"
placeholder="Reminder Date Range"
v-model="filters.reminders"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.assignees') }}</label>
<label class="label">Assignees</label>
<div class="control">
<multiselect
:loading="usersService.loading"
:placeholder="$t('team.edit.search')"
placeholder="Type to search for a user..."
@search="query => find('users', query)"
:search-results="foundusers"
@select="() => add('users', 'assignees')"
@ -130,19 +128,37 @@
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<edit-labels v-model="labels" @change="changeLabelFilter"/>
<label class="label">Labels</label>
<div class="control">
<multiselect
:loading="labelService.loading"
placeholder="Type to search for a label..."
@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>
<template v-if="$route.name === 'filters.create' || $route.name === 'list.edit'">
<div class="field">
<label class="label">{{ $t('list.lists') }}</label>
<label class="label">Lists</label>
<div class="control">
<multiselect
:loading="listsService.loading"
:placeholder="$t('list.search')"
placeholder="Type to search for a list..."
@search="query => find('lists', query)"
:search-results="foundlists"
@select="() => add('lists', 'list_id')"
@ -154,11 +170,11 @@
</div>
</div>
<div class="field">
<label class="label">{{ $t('namespace.namespaces') }}</label>
<label class="label">Namespaces</label>
<div class="control">
<multiselect
:loading="namespaceService.loading"
:placeholder="$t('namespace.search')"
placeholder="Type to search for a namespace..."
@search="query => find('namespace', query)"
:search-results="foundnamespace"
@select="() => add('namespace', 'namespace')"
@ -181,48 +197,18 @@ 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 LabelService from '@/services/label'
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,27 +217,68 @@ 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: '',
},
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
mode: 'range',
},
usersService: new UserService(),
usersService: UserService,
foundusers: [],
users: [],
labelQuery: '',
labelService: LabelService,
foundLabels: [],
labels: [],
listsService: new ListService(),
listsService: ListService,
foundlists: [],
lists: [],
namespaceService: new NamespaceService(),
namespaceService: NamespaceService,
foundnamespace: [],
namespace: [],
}
},
created() {
this.usersService = new UserService()
this.labelService = new LabelService()
this.listsService = new ListService()
this.namespaceService = new NamespaceService()
},
mounted() {
this.params = this.value
this.filters.requireAllFilters = this.params.filter_concat === 'and'
this.prepareFilters()
},
props: {
value: {
@ -259,36 +286,9 @@ export default {
},
},
watch: {
value: {
handler(value) {
this.params = value
this.prepareFilters()
},
immediate: true,
},
},
computed: {
foundLabels() {
const labels = (Object.values(this.$store.state.labels.labels).filter(l => {
return l.title.toLowerCase().includes(this.labelQuery.toLowerCase())
}) ?? [])
return differenceWith(labels, this.labels, (first, second) => {
return first.id === second.id
})
},
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
mode: 'range',
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
value(newVal) {
this.$set(this, 'params', newVal)
this.prepareFilters()
},
},
methods: {
@ -305,12 +305,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.
@ -324,11 +321,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] !== '') {
@ -484,7 +477,7 @@ export default {
.then(r => {
this.$set(this, kind, r)
})
.catch(e => this.$message.error(e))
.catch(e => this.error(e, this))
}
},
setDoneFilter() {
@ -539,7 +532,7 @@ export default {
}))
})
.catch(e => {
this.$message.error(e)
this.error(e, this)
})
},
add(kind, filterName) {
@ -567,8 +560,25 @@ export default {
this.$set(this.filters, filterName, ids.join(','))
this.setSingleValueFilter(filterName, filterName, '', 'in')
},
clearLabels() {
this.$set(this, 'foundLabels', [])
},
findLabels(query) {
this.labelQuery = query
if (query === '') {
this.clearLabels()
}
this.labelService.getAll({}, {s: query})
.then(response => {
// Filter the results to not include labels already selected
this.$set(this, 'foundLabels', differenceWith(response, this.labels, (first, second) => {
return first.id === second.id
}))
})
.catch(e => {
this.error(e, this)
})
},
addLabel() {
this.$nextTick(() => {

View File

@ -1,87 +0,0 @@
<template>
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor),
'has-background': background !== null
}"
:style="{
'background-color': list.hexColor,
'background-image': background !== null ? `url(${background})` : false,
}"
:to="{ name: 'list.index', params: { listId: list.id} }"
class="list-card"
tag="span"
v-if="list !== null && (showArchived ? true : !list.isArchived)"
>
<div class="is-archived-container">
<span class="is-archived" v-if="list.isArchived">
{{ $t('namespace.archived') }}
</span>
<span
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
@click.stop="toggleFavoriteList(list)"
class="favorite">
<icon icon="star" v-if="list.isFavorite"/>
<icon :icon="['far', 'star']" v-else/>
</span>
</div>
<div class="title">{{ list.title }}</div>
</router-link>
</template>
<script>
import ListService from '@/services/list'
export default {
name: 'list-card',
data() {
return {
background: null,
backgroundLoading: false,
}
},
props: {
list: {
required: true,
},
showArchived: {
default: false,
type: Boolean,
},
},
watch: {
list: {
handler: 'loadBackground',
immediate: true,
},
},
methods: {
loadBackground() {
if (this.list === null || !this.list.backgroundInformation || this.backgroundLoading) {
return
}
this.backgroundLoading = true
const listService = new ListService()
listService.background(this.list)
.then(b => {
this.$set(this, 'background', b)
})
.catch(e => {
this.$message.error(e)
})
.finally(() => this.backgroundLoading = false)
},
toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
.catch(e => this.$message.error(e))
},
},
}
</script>

View File

@ -1,34 +1,16 @@
<template>
<div class="content">
<h1>{{ $t('migrate.titleService', {name: name}) }}</h1>
<p>{{ $t('migrate.descriptionDo') }}</p>
<h1>Import your data from {{ name }} to Vikunja</h1>
<p>Vikunja will import all lists, tasks, notes, reminders and files you have access to.</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>To authorize Vikunja to access your {{ name }} Account, click the button below.</p>
<x-button
:loading="migrationService.loading"
:disabled="migrationService.loading"
:href="authUrl"
>
Get Started
</x-button>
</template>
<div
class="migration-in-progress-container"
@ -36,20 +18,28 @@
<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>
<p>{{ $t('migrate.inProgress') }}</p>
<p>Importing in progress, hang tight...</p>
</div>
<div v-else-if="lastMigrationDate">
<p>
{{ $t('migrate.alreadyMigrated1', {name: name, date: formatDate(lastMigrationDate)}) }}<br/>
{{ $t('migrate.alreadyMigrated2') }}
It looks like you've already imported your stuff from {{ name }} at {{ formatDate(lastMigrationDate) }}.<br/>
Importing again is possible, but might create duplicates.
Are you sure?
</p>
<div class="buttons">
<x-button @click="migrate">{{ $t('migrate.confirm') }}</x-button>
<x-button :to="{name: 'home'}" type="tertary" class="has-text-danger">{{ $t('misc.cancel') }}</x-button>
<x-button @click="migrate">I am sure, please start migrating now!</x-button>
<x-button :to="{name: 'home'}" type="tertary" class="has-text-danger">Cancel</x-button>
</div>
</div>
<div v-else>
@ -58,28 +48,23 @@
{{ message }}
</div>
</div>
<x-button :to="{name: 'home'}">{{ $t('misc.refresh') }}</x-button>
<x-button :to="{name: 'home'}">Refresh</x-button>
</div>
</div>
</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 +76,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=')) {
@ -130,7 +105,7 @@ export default {
this.migrate()
})
.catch(e => {
this.$message.error(e)
this.error(e, this)
})
}
},
@ -141,42 +116,20 @@ export default {
this.authUrl = r.url
})
.catch(e => {
this.$message.error(e)
this.error(e, this)
})
},
migrate() {
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
this.$store.dispatch('namespaces/loadNamespaces')
})
.catch(e => {
this.$message.error(e)
})
.finally(() => {
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.$message.error(e)
this.error(e, this)
})
.finally(() => {
this.isMigrating = false

View File

@ -1,13 +1,13 @@
<template>
<div class="api-config">
<div v-if="configureApi">
<label class="label" for="api-url">{{ $t('apiConfig.url') }}</label>
<label class="label" for="api-url">Vikunja URL</label>
<div class="field has-addons">
<div class="control is-expanded">
<input
class="input"
id="api-url"
:placeholder="$t('apiConfig.urlPlaceholder')"
placeholder="eg. https://localhost:3456"
required
type="url"
v-focus
@ -17,17 +17,16 @@
</div>
<div class="control">
<x-button @click="setApiUrl" :disabled="apiUrl === ''">
{{ $t('apiConfig.change') }}
Change
</x-button>
</div>
</div>
</div>
<div class="api-url-info" v-else>
<i18n path="apiConfig.signInOn">
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
</i18n>
Sign in to your Vikunja account on
<span v-tooltip="apiUrl"> {{ apiDomain() }} </span>
<br />
<a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a>
<a @click="() => (configureApi = true)">change</a>
</div>
<div
@ -46,31 +45,32 @@
</template>
<script>
import { parseURL } from 'ufo'
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: {
apiDomain() {
return parseURL(this.apiUrl).host
},
},
methods: {
apiDomain() {
if (window.API_URL.startsWith('/api/v1')) {
return window.location.host
}
const urlParts = window.API_URL.replace('http://', '')
.replace('https://', '')
.split(/[/?#]/)
return urlParts[0]
},
setApiUrl() {
if (this.apiUrl === '') {
return
@ -130,17 +130,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') &&
@ -153,17 +153,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') &&
@ -178,14 +178,14 @@ export default {
.catch(() => {
// Still not found, url is still invalid
this.successMsg = ''
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
this.errorMsg = `Could not find or use Vikunja installation at "${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 = `Using Vikunja installation at "${this.apiDomain()}".`
localStorage.setItem('API_URL', window.API_URL)
this.configureApi = false
this.apiUrl = window.API_URL

View File

@ -26,7 +26,7 @@
type="secondary"
@click.prevent.stop="$router.back()"
>
{{ $t('misc.cancel') }}
Cancel
</x-button>
<x-button
type="primary"
@ -52,9 +52,7 @@ export default {
},
primaryLabel: {
type: String,
default() {
return this.$t('misc.create')
},
default: 'Create',
},
primaryIcon: {
type: String,

View File

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

View File

@ -1,9 +1,7 @@
<template>
<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>
</i18n>
Loading failed, please <a @click="() => location.reload()">try again</a>.
If the error persists, please <a href="https://vikunja.io/contact/">contact us</a>.
</div>
</template>

View File

@ -1,64 +1,59 @@
<template>
<modal @close="close()">
<card class="has-background-white has-no-shadow" :title="$t('keyboardShortcuts.title')">
<div class="message is-primary">
<div class="message-body">
{{ $t('keyboardShortcuts.allPages') }}
</div>
</div>
<div class="modal-mask keyboard-shortcuts-modal">
<div @click.self="close()" class="modal-container">
<div class="modal-content">
<card class="has-background-white has-no-shadow" title="Available Keyboard Shortcuts">
<p>
<strong>{{ $t('keyboardShortcuts.toggleMenu') }}</strong>
<strong>Toggle The Menu</strong>
<shortcut :keys="['ctrl', 'e']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.quickSearch') }}</strong>
<shortcut :keys="['ctrl', 'k']"/>
</p>
<h3>{{ $t('list.kanban.title') }}</h3>
<h3>Kanban</h3>
<div class="message is-primary" v-if="$route.name === 'list.kanban'">
<div class="message-body">
{{ $t('keyboardShortcuts.currentPageOnly') }}
These shortcuts work on the current page.
</div>
</div>
<p>
<strong>{{ $t('keyboardShortcuts.task.done') }}</strong>
<strong>Mark a task as done</strong>
<shortcut :keys="['ctrl', 'click']"/>
</p>
<h3>{{ $t('keyboardShortcuts.task.title') }}</h3>
<h3>Task Page</h3>
<div
class="message is-primary"
v-if="$route.name === 'task.detail' || $route.name === 'task.list.detail' || $route.name === 'task.gantt.detail' || $route.name === 'task.kanban.detail' || $route.name === 'task.detail'">
<div class="message-body">
{{ $t('keyboardShortcuts.currentPageOnly') }}
These shortcuts work on the current page.
</div>
</div>
<p>
<strong>{{ $t('keyboardShortcuts.task.assign') }}</strong>
<strong>Assign this task to a user</strong>
<shortcut :keys="['a']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.task.labels') }}</strong>
<strong>Add labels to this task</strong>
<shortcut :keys="['l']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.task.dueDate') }}</strong>
<strong>Change the due date of this task</strong>
<shortcut :keys="['d']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.task.attachment') }}</strong>
<strong>Add an attachment to this task</strong>
<shortcut :keys="['f']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.task.related') }}</strong>
<strong>Modify related tasks of this task</strong>
<shortcut :keys="['r']"/>
</p>
</card>
</modal>
</div>
</div>
</div>
</template>
<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',
@ -69,4 +64,4 @@ export default {
},
},
}
</script>
</script>

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">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">Privacy policy</a>
</div>
</template>

View File

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

View File

@ -1,24 +1,29 @@
<template>
<notifications position="bottom left" :max="2" class="global-notification">
<template #body="{ item, close }">
<template slot="body" slot-scope="props">
<div
:class="[
'vue-notification-template',
'vue-notification',
item.type,
props.item.type,
]"
@click="close()"
@click="close(props)"
>
<div v-if="item.title" class="notification-title">{{ item.title }}</div>
<div class="notification-content">
<template v-for="(t, k) in item.text">{{ t }}<br :key="k"/></template>
</div>
<div
class="notification-title"
v-html="props.item.title"
v-if="props.item.title"
></div>
<div
class="notification-content"
v-html="props.item.text"
></div>
<div
class="buttons is-right"
v-if="
item.data &&
item.data.actions &&
item.data.actions.length > 0
props.item.data &&
props.item.data.actions &&
props.item.data.actions.length > 0
"
>
<x-button
@ -27,7 +32,7 @@
:shadow="false"
class="is-small"
type="secondary"
v-for="(action, i) in item.data.actions"
v-for="(action, i) in props.item.data.actions"
>
{{ action.title }}
</x-button>
@ -40,6 +45,11 @@
<script>
export default {
name: 'notification',
methods: {
close(props) {
props.close()
},
},
}
</script>

View File

@ -1,125 +0,0 @@
<template>
<nav
aria-label="pagination"
class="pagination is-centered p-4"
role="navigation"
v-if="totalPages > 1"
>
<router-link
:disabled="currentPage === 1"
:to="getRouteForPagination(currentPage - 1)"
class="pagination-previous"
tag="button">
{{ $t('misc.previous') }}
</router-link>
<router-link
:disabled="currentPage === totalPages"
:to="getRouteForPagination(currentPage + 1)"
class="pagination-next"
tag="button">
{{ $t('misc.next') }}
</router-link>
<ul class="pagination-list">
<template v-for="(p, i) in pages">
<li :key="'page' + i" v-if="p.isEllipsis">
<span class="pagination-ellipsis">&hellip;</span>
</li>
<li :key="'page' + i" v-else>
<router-link
:aria-label="'Goto page ' + p.number"
:class="{ 'is-current': p.number === currentPage }"
:to="getRouteForPagination(p.number)"
class="pagination-link"
>
{{ p.number }}
</router-link>
</li>
</template>
</ul>
</nav>
</template>
<script>
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
}
function getRouteForPagination(page = 1, type = 'list') {
return {
name: 'list.' + type,
params: {
type: type,
},
query: {
page: page,
},
}
}
export default {
name: 'Pagination',
props: {
totalPages: {
type: Number,
required: true,
},
currentPage: {
type: Number,
required: true,
},
},
computed: {
pages() {
return createPagination(this.totalPages, this.currentPage)
},
},
methods: {
getRouteForPagination,
},
}
</script>
<style lang="scss" scoped>
.pagination {
padding-bottom: 1rem;
.pagination-previous,
.pagination-next {
&:not(:disabled):hover {
background: $scheme-main;
cursor: pointer;
}
}
}
</style>

View File

@ -1,8 +1,8 @@
<template>
<span class="shortcuts">
<template v-for="(k, i) in keys">
<kbd :key="i">{{ k }}</kbd>
<span v-if="i < keys.length - 1" :key="`plus${i}`">+</span>
<span :key="i">{{ k }}</span>
<i v-if="i < keys.length - 1" :key="`plus${i}`">+</i>
</template>
</span>
</template>
@ -14,26 +14,7 @@ export default {
keys: {
type: Array,
required: true,
},
}
},
}
</script>
<style lang="scss" scoped>
.shortcuts {
display: flex;
align-items: center;
}
kbd {
padding: .1rem .35rem;
border: 1px solid $grey-300;
background: $grey-100;
border-radius: 3px;
font-size: .75rem;
}
span {
padding: 0 .25rem;
}
</style>

View File

@ -30,7 +30,7 @@ export default {
name: 'task-subscription',
data() {
return {
subscriptionService: new SubscriptionService(),
subscriptionService: SubscriptionService,
}
},
props: {
@ -49,21 +49,21 @@ export default {
default: true,
},
},
created() {
this.subscriptionService = new SubscriptionService()
},
computed: {
tooltipText() {
if (this.disabled) {
return this.$t('task.subscription.subscribedThroughParent', {
entity: this.entity,
parent: this.subscription.entity,
})
if(this.disabled) {
return `You can't unsubscribe here because you are subscribed to this ${this.entity} through its ${this.subscription.entity}.`
}
return this.subscription !== null ?
this.$t('task.subscription.subscribed', {entity: this.entity}) :
this.$t('task.subscription.notSubscribed', {entity: this.entity})
`You are currently subscribed to this ${this.entity} and will receive notifications for changes.` :
`You are not subscribed to this ${this.entity} and won't receive notifications for changes.`
},
buttonText() {
return this.subscription !== null ? this.$t('task.subscription.unsubscribe') : this.$t('task.subscription.subscribe')
return this.subscription !== null ? 'Unsubscribe' : 'Subscribe'
},
icon() {
return this.subscription !== null ? ['far', 'bell-slash'] : 'bell'
@ -78,7 +78,7 @@ export default {
},
methods: {
changeSubscription() {
if (this.disabled) {
if(this.disabled) {
return
}
@ -96,10 +96,10 @@ export default {
this.subscriptionService.create(subscription)
.then(() => {
this.$emit('change', subscription)
this.$message.success({message: this.$t('task.subscription.subscribeSuccess', {entity: this.entity})})
this.success({message: `You are now subscribed to this ${this.entity}`}, this)
})
.catch(e => {
this.$message.error(e)
this.error(e, this)
})
},
unsubscribe() {
@ -110,12 +110,12 @@ export default {
this.subscriptionService.delete(subscription)
.then(() => {
this.$emit('change', null)
this.$message.success({message: this.$t('task.subscription.unsubscribeSuccess', {entity: this.entity})})
this.success({message: `You are now unsubscribed to this ${this.entity}`}, this)
})
.catch(e => {
this.$message.error(e)
this.error(e, this)
})
},
}
},
}
</script>

View File

@ -47,6 +47,8 @@ export default {
}
img {
-webkit-border-radius: 100%;
-moz-border-radius: 100%;
border-radius: 100%;
vertical-align: middle;

View File

@ -1,27 +1,8 @@
<template>
<transition name="modal">
<section
v-if="enabled"
class="modal-mask"
:class="[
{ 'has-overflow': overflow },
variant,
]"
>
<div
class="modal-container"
:class="{'has-overflow': overflow}"
@click.self.prevent.stop="$emit('close')"
@shortkey="$emit('close')"
v-shortkey="['esc']"
>
<div
class="modal-content"
:class="{
'has-overflow': overflow,
'is-wide': wide
}"
>
<div class="modal-mask">
<div class="modal-container" @mousedown.self.prevent.stop="$emit('close')">
<div class="modal-content" :class="{'has-overflow': overflow, 'is-wide': wide}">
<slot>
<div class="header">
<slot name="header"></slot>
@ -35,42 +16,27 @@
type="tertary"
class="has-text-danger"
>
{{ $t('misc.cancel') }}
Cancel
</x-button>
<x-button
@click="$emit('submit')"
type="primary"
:shadow="false"
>
{{ $t('misc.doit') }}
Do it!
</x-button>
</div>
</slot>
</div>
</div>
</section>
</div>
</transition>
</template>
<script>
export const TRANSITION_NAMES = {
MODAL: 'modal',
FADE: 'fade',
}
export const VARIANTS = {
DEFAULT: 'default',
HINT_MODAL: 'hint-modal',
SCROLLING: 'scrolling',
}
function validValue(values) {
return (value) => Object.values(values).includes(value)
}
export default {
name: 'modal',
mounted() {
mounted: function () {
document.addEventListener('keydown', (e) => {
// Close the model when escape is pressed
if (e.keyCode === 27) {
@ -79,10 +45,6 @@ export default {
})
},
props: {
enabled: {
type: Boolean,
default: true,
},
overflow: {
type: Boolean,
default: false,
@ -91,126 +53,6 @@ export default {
type: Boolean,
default: false,
},
transitionName: {
type: String,
default: TRANSITION_NAMES.MODAL,
validator: validValue(TRANSITION_NAMES),
},
variant: {
type: String,
default: VARIANTS.DEFAULT,
validator: validValue(VARIANTS),
},
},
}
</script>
<style lang="scss" scoped>
.modal-mask {
position: fixed;
z-index: 4000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, .8);
transition: opacity 150ms ease;
color: #fff;
}
.modal-container {
transition: all 150ms ease;
position: relative;
width: 100%;
height: 100%;
max-height: 100vh;
overflow: auto;
}
.default .modal-content,
.hint-modal .modal-content {
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@media screen and (max-width: $tablet) {
margin: 0;
top: 25%;
transform: translate(-50%, -25%);
}
.header {
font-size: 2rem;
font-weight: 700;
}
.button {
margin: 0 0.5rem;
}
}
// scrolling-content
// used e.g. for <TaskDetailViewModal>
.scrolling .modal-content {
max-width: 1024px;
width: 100%;
margin: 4rem auto;
max-height: none; // reset bulma
overflow: visible; // reset bulma
@media screen and (min-width: $tablet) {
max-height: none; // reset bulma
margin: 4rem auto; // reset bulma
width: 100%;
}
@media screen and (max-width: $desktop) {
margin: 0;
}
}
.is-wide {
max-width: $desktop;
width: calc(100% - 2rem);
}
.hint-modal {
z-index: 4600;
::v-deep.card-content {
text-align: left;
.info {
font-style: italic;
}
p {
display: flex;
justify-content: space-between;
align-items: center;
}
.message-body {
padding: .5rem .75rem;
}
}
}
/* Transitions */
.modal-enter,
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
transform: scale(0.9);
}
</style>

View File

@ -1,43 +1,53 @@
<template>
<multiselect
:placeholder="$t('namespace.search')"
:loading="namespaceService.loading"
placeholder="Search for a namespace..."
@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, this)
})
},
clearAll() {
this.$set(this, 'namespaces', [])
},
select(namespace) {
this.$emit('selected', namespace)

View File

@ -5,7 +5,7 @@
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
{{ $t('menu.unarchive') }}
Un-Archive
</dropdown-item>
</template>
<template v-else>
@ -13,25 +13,25 @@
:to="{ name: 'namespace.settings.edit', params: { id: namespace.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
Edit
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.share', params: { id: namespace.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
Share
</dropdown-item>
<dropdown-item
:to="{ name: 'list.create', params: { id: namespace.id } }"
icon="plus"
>
{{ $t('menu.newList') }}
New list
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
{{ $t('menu.archive') }}
Archive
</dropdown-item>
<task-subscription
class="dropdown-item has-no-shadow"
@ -46,16 +46,16 @@
icon="trash-alt"
class="has-text-danger"
>
{{ $t('menu.delete') }}
Delete
</dropdown-item>
</template>
</dropdown>
</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

@ -1,15 +1,13 @@
<template>
<div class="notifications">
<div class="is-flex is-justify-content-center">
<a @click.stop="showNotifications = !showNotifications" class="trigger-button">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/>
</a>
</div>
<a @click.stop="showNotifications = !showNotifications" class="trigger">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/>
</a>
<transition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
<span class="head">{{ $t('notification.title') }}</span>
<span class="head">Notifications</span>
<div
v-for="(n, index) in notifications"
:key="n.id"
@ -37,9 +35,9 @@
</span>
</div>
<p class="nothing" v-if="notifications.length === 0">
{{ $t('notification.none') }}<br/>
You don't have any notifications. Have a nice day!<br/>
<span class="explainer">
{{ $t('notification.explainer') }}
Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen.
</span>
</p>
</div>
@ -49,28 +47,29 @@
<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'
const LOAD_NOTIFICATIONS_INTERVAL = 10000
export default {
name: 'notifications',
components: {User},
data() {
return {
notificationService: new NotificationService(),
notificationService: NotificationService,
allNotifications: [],
showNotifications: false,
interval: null,
}
},
created() {
this.notificationService = new NotificationService()
},
mounted() {
this.loadNotifications()
document.addEventListener('click', this.hidePopup)
this.interval = setInterval(this.loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
this.interval = setInterval(this.loadNotifications, 10000)
},
beforeDestroy() {
document.removeEventListener('click', this.hidePopup)
@ -99,7 +98,7 @@ export default {
this.$set(this, 'allNotifications', r)
})
.catch(e => {
this.$message.error(e)
this.error(e, this)
})
},
to(n, index) {
@ -137,7 +136,7 @@ export default {
.then(r => {
this.$set(this.allNotifications, index, r)
})
.catch(e => this.$message.error(e))
.catch(e => this.error(e, this))
}
},
},

View File

@ -1,505 +0,0 @@
<template>
<modal v-if="active" class="quick-actions" @close="closeQuickActions" :overflow="isNewTaskCommand">
<div class="card">
<div class="action-input" :class="{'has-active-cmd': selectedCmd !== null}">
<div class="active-cmd tag" v-if="selectedCmd !== null">
{{ selectedCmd.title }}
</div>
<input
v-focus
class="input"
:class="{'is-loading': loading}"
v-model="query"
:placeholder="placeholder"
@keyup="search"
ref="searchInput"
@keydown.down.prevent="() => select(0, 0)"
@keyup.prevent.delete="unselectCmd"
@keyup.prevent.enter="doCmd"
@keyup.prevent.esc="closeQuickActions"
/>
</div>
<div class="help has-text-grey-light p-2" v-if="hintText !== '' && !isNewTaskCommand">
{{ hintText }}
</div>
<quick-add-magic class="p-2 modal-container-smaller" v-if="isNewTaskCommand"/>
<div class="results" v-if="selectedCmd === null">
<div v-for="(r, k) in results" :key="k" class="result">
<span class="result-title">
{{ r.title }}
</span>
<div class="result-items">
<button
v-for="(i, key) in r.items"
:key="key"
:ref="`result-${k}_${key}`"
@keydown.up.prevent="() => select(k, key - 1)"
@keydown.down.prevent="() => select(k, key + 1)"
@click.prevent.stop="() => doAction(r.type, i)"
@keyup.prevent.enter="() => doAction(r.type, i)"
@keyup.prevent.esc="() => $refs.searchInput.focus()"
:class="{'is-strikethrough': i.done}"
>
{{ i.title }}
</button>
</div>
</div>
</div>
</div>
</modal>
</template>
<script>
import TaskService from '@/services/task'
import TeamService from '@/services/team'
import NamespaceModel from '@/models/namespace'
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'
const TYPE_LIST = 'list'
const TYPE_TASK = 'task'
const TYPE_CMD = 'cmd'
const TYPE_TEAM = 'team'
const CMD_NEW_TASK = 'newTask'
const CMD_NEW_LIST = 'newList'
const CMD_NEW_NAMESPACE = 'newNamespace'
const CMD_NEW_TEAM = 'newTeam'
const SEARCH_MODE_ALL = 'all'
const SEARCH_MODE_TASKS = 'tasks'
const SEARCH_MODE_LISTS = 'lists'
const SEARCH_MODE_TEAMS = 'teams'
export default {
name: 'quick-actions',
components: {QuickAddMagic},
data() {
return {
query: '',
selectedCmd: null,
foundTasks: [],
taskSearchTimeout: null,
taskService: new TaskService(),
foundTeams: [],
teamSearchTimeout: null,
teamService: new TeamService(),
}
},
mixins: [
createTask,
],
computed: {
active() {
const active = this.$store.state[QUICK_ACTIONS_ACTIVE]
if (!active) {
this.reset()
}
return active
},
results() {
let lists = []
if (this.searchMode === SEARCH_MODE_ALL || this.searchMode === SEARCH_MODE_LISTS) {
let query = this.query
if (this.searchMode === SEARCH_MODE_LISTS) {
query = query.substr(1)
}
const ncache = {}
const history = getHistory()
// Puts recently visited lists at the top
const allLists = [...new Set([
...history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
}),
...Object.values(this.$store.state.lists)])]
lists = (allLists.filter(l => {
if (typeof l === 'undefined' || l === null) {
return false
}
if (l.isArchived) {
return false
}
if (typeof ncache[l.namespaceId] === 'undefined') {
ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId)
}
if (ncache[l.namespaceId].isArchived) {
return false
}
return l.title.toLowerCase().includes(query.toLowerCase())
}) ?? [])
}
const cmds = this.availableCmds
.filter(a => a.title.toLowerCase().includes(this.query.toLowerCase()))
return [
{
type: TYPE_CMD,
title: this.$t('quickActions.commands'),
items: cmds,
},
{
type: TYPE_TASK,
title: this.$t('quickActions.tasks'),
items: this.foundTasks,
},
{
type: TYPE_LIST,
title: this.$t('quickActions.lists'),
items: lists,
},
{
type: TYPE_TEAM,
title: this.$t('quickActions.teams'),
items: this.foundTeams,
},
].filter(i => i.items.length > 0)
},
nothing() {
return this.search === '' || Object.keys(this.results).length === 0
},
loading() {
return this.taskService.loading ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'namespaces') ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'lists') ||
this.teamService.loading
},
placeholder() {
if (this.selectedCmd !== null) {
switch (this.selectedCmd.action) {
case CMD_NEW_TASK:
return this.$t('quickActions.newTask')
case CMD_NEW_LIST:
return this.$t('quickActions.newList')
case CMD_NEW_NAMESPACE:
return this.$t('quickActions.newNamespace')
case CMD_NEW_TEAM:
return this.$t('quickActions.newTeam')
}
}
return this.$t('quickActions.placeholder')
},
hintText() {
let namespace
if (this.selectedCmd !== null && this.currentList !== null) {
switch (this.selectedCmd.action) {
case CMD_NEW_TASK:
return this.$t('quickActions.createTask', {title: this.currentList.title})
case CMD_NEW_LIST:
namespace = this.$store.getters['namespaces/getNamespaceById'](this.currentList.namespaceId)
return this.$t('quickActions.createList', {title: namespace.title})
}
}
return this.$t('quickActions.hint')
},
currentList() {
return Object.keys(this.$store.state[CURRENT_LIST]).length === 0 ? null : this.$store.state[CURRENT_LIST]
},
availableCmds() {
const cmds = []
if (this.currentList !== null) {
cmds.push({
title: this.$t('quickActions.cmds.newTask'),
action: CMD_NEW_TASK,
})
cmds.push({
title: this.$t('quickActions.cmds.newList'),
action: CMD_NEW_LIST,
})
}
cmds.push({
title: this.$t('quickActions.cmds.newNamespace'),
action: CMD_NEW_NAMESPACE,
})
cmds.push({
title: this.$t('quickActions.cmds.newTeam'),
action: CMD_NEW_TEAM,
})
return cmds
},
searchMode() {
if (this.query === '') {
return SEARCH_MODE_ALL
}
if (this.query.startsWith('#')) {
return SEARCH_MODE_TASKS
}
if (this.query.startsWith('*')) {
return SEARCH_MODE_LISTS
}
if (this.query.startsWith('@')) {
return SEARCH_MODE_TEAMS
}
return SEARCH_MODE_ALL
},
isNewTaskCommand() {
return this.selectedCmd !== null && this.selectedCmd.action === CMD_NEW_TASK
},
},
methods: {
search() {
this.searchTasks()
this.searchTeams()
},
searchTasks() {
if (this.searchMode !== SEARCH_MODE_ALL && this.searchMode !== SEARCH_MODE_TASKS) {
this.foundTasks = []
return
}
let query = this.query
if (this.searchMode === SEARCH_MODE_TASKS) {
query = query.substr(1)
}
if (query === '' || this.selectedCmd !== null) {
return
}
if (this.taskSearchTimeout !== null) {
clearTimeout(this.taskSearchTimeout)
this.taskSearchTimeout = null
}
this.taskSearchTimeout = setTimeout(() => {
this.taskService.getAll({}, {s: query})
.then(r => {
r = r.map(t => {
t.type = TYPE_TASK
const list = this.$store.getters['lists/getListById'](t.listId) === null ? null : this.$store.getters['lists/getListById'](t.listId)
if (list !== null) {
t.title = `${t.title} (${list.title})`
}
return t
})
this.$set(this, 'foundTasks', r)
})
}, 150)
},
searchTeams() {
if (this.searchMode !== SEARCH_MODE_ALL && this.searchMode !== SEARCH_MODE_TEAMS) {
this.foundTeams = []
return
}
let query = this.query
if (this.searchMode === SEARCH_MODE_TEAMS) {
query = query.substr(1)
}
if (query === '' || this.selectedCmd !== null) {
return
}
if (this.teamSearchTimeout !== null) {
clearTimeout(this.teamSearchTimeout)
this.teamSearchTimeout = null
}
this.teamSearchTimeout = setTimeout(() => {
this.teamService.getAll({}, {s: query})
.then(r => {
r = r.map(t => {
t.title = t.name
return t
})
this.$set(this, 'foundTeams', r)
})
}, 150)
},
closeQuickActions() {
this.$store.commit(QUICK_ACTIONS_ACTIVE, false)
},
doAction(type, item) {
switch (type) {
case TYPE_LIST:
this.$router.push({name: 'list.index', params: {listId: item.id}})
this.closeQuickActions()
break
case TYPE_TASK:
this.$router.push({name: 'task.detail', params: {id: item.id}})
this.closeQuickActions()
break
case TYPE_CMD:
this.query = ''
this.selectedCmd = item
this.$refs.searchInput.focus()
break
}
},
doCmd() {
if (this.results.length === 1 && this.results[0].items.length === 1) {
this.doAction(this.results[0].type, this.results[0].items[0])
return
}
if (this.selectedCmd === null) {
return
}
if (this.query === '') {
return
}
switch (this.selectedCmd.action) {
case CMD_NEW_TASK:
this.newTask()
break
case CMD_NEW_LIST:
this.newList()
break
case CMD_NEW_NAMESPACE:
this.newNamespace()
break
case CMD_NEW_TEAM:
this.newTeam()
break
}
},
newTask() {
if (this.currentList === null) {
return
}
this.createNewTask(this.query, 0, this.currentList.id)
.then(r => {
this.$message.success({message: this.$t('task.createSuccess')})
this.$router.push({name: 'task.detail', params: {id: r.id}})
this.closeQuickActions()
})
.catch((e) => {
this.$message.error(e)
})
},
newList() {
if (this.currentList === null) {
return
}
const newList = new ListModel({
title: this.query,
namespaceId: this.currentList.namespaceId,
})
this.$store.dispatch('lists/createList', newList)
.then(r => {
this.$message.success({message: this.$t('list.create.createdSuccess')})
this.$router.push({name: 'list.index', params: {listId: r.id}})
this.closeQuickActions()
})
.catch((e) => {
this.$message.error(e)
})
},
newNamespace() {
const newNamespace = new NamespaceModel({title: this.query})
this.$store.dispatch('namespaces/createNamespace', newNamespace)
.then(() => {
this.$message.success({message: this.$t('namespace.create.success')})
this.closeQuickActions()
})
.catch((e) => {
this.$message.error(e)
})
},
newTeam() {
const newTeam = new TeamModel({name: this.query})
this.teamService.create(newTeam)
.then(r => {
this.$router.push({
name: 'teams.edit',
params: {id: r.id},
})
this.$message.success({message: this.$t('team.create.success')})
this.closeQuickActions()
})
.catch((e) => {
this.$message.error(e)
})
},
select(parentIndex, index) {
if (index < 0 && parentIndex === 0) {
this.$refs.searchInput.focus()
return
}
if (index < 0) {
parentIndex--
index = this.results[parentIndex].items.length - 1
}
let elems = this.$refs[`result-${parentIndex}_${index}`]
if (this.results[parentIndex].items.length === index) {
elems = this.$refs[`result-${parentIndex + 1}_0`]
}
if (typeof elems === 'undefined' || elems.length === 0) {
return
}
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems.focus()
},
unselectCmd() {
if (this.query !== '') {
return
}
this.selectedCmd = null
},
reset() {
this.query = ''
this.selectedCmd = null
},
},
}
</script>
<style lang="scss" scoped>
.quick-actions {
// FIXME: changed position should be an option of the modal
::v-deep.modal-content {
top: 3rem;
transform: translate(-50%, 0);
}
}
// HACK:
// FIXME:
.modal-container-smaller ::v-deep.hint-modal .modal-container {
height: calc(100vh - 5rem);
}
</style>

View File

@ -1,76 +1,72 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('list.share.links.title') }}
Share Links
<span
class="is-size-7"
v-tooltip="$t('list.share.links.explanation')">
{{ $t('list.share.links.what') }}
v-tooltip="'Share Links allow you to easily share a list with other users who don\'t have an account on Vikunja.'">
What is a share link?
</span>
</p>
<div class="sharables-list">
<x-button
v-if="!(linkShares.length === 0 || showNewForm)"
@click="showNewForm = true"
icon="plus"
class="mb-4">
{{ $t('list.share.links.create') }}
Create a new link share
</x-button>
<div class="p-4" v-if="linkShares.length === 0 || showNewForm">
<div class="field">
<label class="label" for="linkShareRight">
{{ $t('list.share.right.title') }}
Right
</label>
<div class="control">
<div class="select">
<select v-model="selectedRight" id="linkShareRight">
<option :value="rights.READ">
{{ $t('list.share.right.read') }}
</option>
<option :value="rights.READ">Read only</option>
<option :value="rights.READ_WRITE">
{{ $t('list.share.right.readWrite') }}
</option>
<option :value="rights.ADMIN">
{{ $t('list.share.right.admin') }}
Read & write
</option>
<option :value="rights.ADMIN">Admin</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label" for="linkShareName">
{{ $t('list.share.links.name') }}
Name (optional)
</label>
<div class="control">
<input
id="linkShareName"
class="input"
:placeholder="$t('list.share.links.namePlaceholder')"
v-tooltip="$t('list.share.links.nameExplanation')"
placeholder="e.g. Lorem Ipsum"
v-tooltip="'All actions done by this link share will show up with the name.'"
v-model="name"
/>
</div>
</div>
<div class="field">
<label class="label" for="linkSharePassword">
{{ $t('list.share.links.password') }}
Password (optional)
</label>
<div class="control">
<input
id="linkSharePassword"
type="password"
class="input"
:placeholder="$t('user.auth.passwordPlaceholder')"
v-tooltip="$t('list.share.links.passwordExplanation')"
placeholder="e.g. ••••••••••••"
v-tooltip="'When authenticating, the user will be required to enter this password.'"
v-model="password"
/>
</div>
</div>
<x-button @click="add" icon="plus">
{{ $t('list.share.share') }}
</x-button>
<x-button @click="add" icon="plus">Share</x-button>
</div>
<table
@ -79,11 +75,11 @@
>
<thead>
<tr>
<th>{{ $t('list.share.attributes.link') }}</th>
<th>{{ $t('list.share.attributes.name') }}</th>
<th>{{ $t('list.share.attributes.sharedBy') }}</th>
<th>{{ $t('list.share.attributes.right') }}</th>
<th>{{ $t('list.share.attributes.delete') }}</th>
<th>Link</th>
<th>Name</th>
<th>Shared&nbsp;by</th>
<th>Right</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
@ -102,7 +98,7 @@
<x-button
@click="copy(getShareLink(s.hash))"
:shadow="false"
v-tooltip="$t('misc.copy')"
v-tooltip="'Copy to clipboard'"
>
<span class="icon">
<icon icon="paste"/>
@ -115,7 +111,7 @@
<template v-if="s.name !== ''">
{{ s.name }}
</template>
<i v-else>{{ $t('list.share.links.noName') }}</i>
<i v-else>No name set</i>
</td>
<td>
{{ s.sharedBy.getDisplayName() }}
@ -125,19 +121,19 @@
<span class="icon is-small">
<icon icon="lock"/>
</span>&nbsp;
{{ $t('list.share.right.admin') }}
Admin
</template>
<template v-else-if="s.right === rights.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>&nbsp;
{{ $t('list.share.right.readWrite') }}
Write
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>&nbsp;
{{ $t('list.share.right.read') }}
Read-only
</template>
</td>
<td class="actions">
@ -163,20 +159,20 @@
@submit="remove()"
v-if="showDeleteModal"
>
<template #header>
<span>{{ $t('list.share.links.remove') }}</span>
</template>
<template #text>
<p>{{ $t('list.share.links.removeText') }}</p>
</template>
<span slot="header">Remove a link share</span>
<p slot="text">
Are you sure you want to remove this link share?<br/>
It will no longer be possible to access this list with this link
share.<br/>
<b>This CANNOT BE UNDONE!</b>
</p>
</modal>
</transition>
</div>
</template>
<script>
import rights from '../../models/constants/rights'
import rights from '../../models/rights'
import LinkShareService from '../../services/linkShare'
import LinkShareModel from '../../models/linkShare'
@ -195,7 +191,8 @@ export default {
data() {
return {
linkShares: [],
linkShareService: new LinkShareService(),
linkShareService: LinkShareService,
newLinkShare: LinkShareModel,
rights: rights,
selectedRight: rights.READ,
name: '',
@ -205,10 +202,17 @@ export default {
showNewForm: false,
}
},
beforeMount() {
this.linkShareService = new LinkShareService()
},
created() {
this.linkShareService = new LinkShareService()
this.load()
},
watch: {
listId: {
handler: 'load',
immediate: true,
listId() {
// watch it
this.load()
},
},
computed: mapState({
@ -227,7 +231,7 @@ export default {
this.linkShares = r
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
add() {
@ -244,11 +248,14 @@ export default {
this.name = ''
this.password = ''
this.showNewForm = false
this.$message.success({message: this.$t('list.share.links.createSuccess')})
this.success(
{message: 'The link share was successfully created'},
this
)
this.load()
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
remove() {
@ -259,17 +266,22 @@ export default {
this.linkShareService
.delete(linkshare)
.then(() => {
this.$message.success({message: this.$t('list.share.links.deleteSuccess')})
this.success(
{message: 'The link share was successfully deleted'},
this
)
this.load()
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
})
},
copy,
copy(text) {
copy(text)
},
getShareLink(hash) {
return this.frontendUrl + 'share/' + hash + '/auth'
},

View File

@ -1,8 +1,6 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('list.share.userTeam.shared', {type: shareTypeNames}) }}
</p>
<p class="has-text-weight-bold">Shared with these {{ shareType }}s</p>
<div v-if="userIsAdmin">
<div class="field has-addons">
<p
@ -11,7 +9,7 @@
>
<multiselect
:loading="searchService.loading"
:placeholder="$t('misc.searchPlaceholder')"
placeholder="Type to search..."
@search="find"
:search-results="found"
:label="searchLabel"
@ -19,7 +17,7 @@
/>
</p>
<p class="control">
<x-button @click="add()">{{ $t('list.share.share') }}</x-button>
<x-button @click="add()"> Share</x-button>
</p>
</div>
</div>
@ -31,7 +29,7 @@
<td>{{ s.getDisplayName() }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('list.share.userTeam.you') }}</b>
<b class="is-success">You</b>
</template>
</td>
</template>
@ -52,19 +50,19 @@
<span class="icon is-small">
<icon icon="lock"/>
</span>
{{ $t('list.share.right.admin') }}
Admin
</template>
<template v-else-if="s.right === rights.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
{{ $t('list.share.right.readWrite') }}
Write
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
{{ $t('list.share.right.read') }}
Read-only
</template>
</td>
<td class="actions" v-if="userIsAdmin">
@ -78,19 +76,19 @@
:selected="s.right === rights.READ"
:value="rights.READ"
>
{{ $t('list.share.right.read') }}
Read only
</option>
<option
:selected="s.right === rights.READ_WRITE"
:value="rights.READ_WRITE"
>
{{ $t('list.share.right.readWrite') }}
Read & write
</option>
<option
:selected="s.right === rights.ADMIN"
:value="rights.ADMIN"
>
{{ $t('list.share.right.admin') }}
Admin
</option>
</select>
</div>
@ -110,7 +108,7 @@
</table>
<nothing v-else>
{{ $t('list.share.userTeam.notShared', {type: shareTypeNames}) }}
Not shared with any {{ shareType }} yet.
</nothing>
<transition name="modal">
@ -119,18 +117,22 @@
@submit="deleteSharable()"
v-if="showDeleteModal"
>
<template #header>
<span>{{ $t('list.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName}) }}</span>
</template>
<template #text>
<p>{{ $t('list.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
</template>
<span slot="header"
>Remove a {{ shareType }} from the {{ typeString }}</span
>
<p slot="text">
Are you sure you want to remove this {{ shareType }} from the
{{ typeString }}?<br/>
<b>This CANNOT BE UNDONE!</b>
</p>
</modal>
</transition>
</div>
</template>
<script>
import {mapState} from 'vuex'
import UserNamespaceService from '../../services/userNamespace'
import UserNamespaceModel from '../../models/userNamespace'
import UserListModel from '../../models/userList'
@ -145,9 +147,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',
@ -190,44 +192,9 @@ export default {
Nothing,
Multiselect,
},
computed: {
userInfo() {
return this.$store.state.auth.info
},
shareTypeNames() {
if (this.shareType === 'user') {
return this.$tc('list.share.userTeam.typeUser', 2)
}
if (this.shareType === 'team') {
return this.$tc('list.share.userTeam.typeTeam', 2)
}
return ''
},
shareTypeName() {
if (this.shareType === 'user') {
return this.$tc('list.share.userTeam.typeUser', 1)
}
if (this.shareType === 'team') {
return this.$tc('list.share.userTeam.typeTeam', 1)
}
return ''
},
sharableName() {
if (this.type === 'list') {
return this.$t('list.list.title')
}
if (this.shareType === 'namespace') {
return this.$t('namespace.namespace')
}
return ''
},
},
computed: mapState({
userInfo: (state) => state.auth.info,
}),
created() {
if (this.shareType === 'user') {
this.searchService = new UserService()
@ -235,11 +202,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 +220,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,11 +245,11 @@ 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) => {
this.$message.error(e)
this.error(e, this)
})
},
deleteSharable() {
@ -291,23 +258,34 @@ export default {
} else if (this.shareType === 'team') {
this.stuffModel.teamId = this.sharable.id
}
this.stuffService
.delete(this.stuffModel)
.then(() => {
this.showDeleteModal = false
for (const i in this.sharables) {
if (
(this.sharables[i].username === this.stuffModel.userId && this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamId && this.shareType === 'team')
(this.sharables[i].id === this.stuffModel.userId &&
this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamId &&
this.shareType === 'team')
) {
this.sharables.splice(i, 1)
}
}
this.$message.success({message: this.$t('list.share.userTeam.removeSuccess', {type: this.shareTypeName, sharable: this.sharableName})})
this.success(
{
message:
'The ' +
this.shareType +
' was successfully deleted from the ' +
this.typeString +
'.',
},
this
)
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
add(admin) {
@ -328,11 +306,19 @@ export default {
this.stuffService
.create(this.stuffModel)
.then(() => {
this.$message.success({message: this.$t('list.share.userTeam.addedSuccess', {type: this.shareTypeName})})
this.success(
{
message:
'The ' +
this.shareType +
' was successfully added.',
},
this
)
this.load()
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
toggleType(sharable) {
@ -365,10 +351,18 @@ export default {
this.$set(this.sharables[i], 'right', r.right)
}
}
this.$message.success({message: this.$t('list.share.userTeam.updatedSuccess', {type: this.shareTypeName})})
this.success(
{
message:
'The ' +
this.shareType +
' right was successfully updated.',
},
this
)
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
find(query) {
@ -383,7 +377,7 @@ export default {
this.$set(this, 'found', response)
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
clearAll() {

View File

@ -1,148 +0,0 @@
<template>
<div class="task-add">
<div class="field is-grouped">
<p class="control has-icons-left is-expanded">
<textarea
:disabled="taskService.loading || null"
class="input"
:placeholder="$t('list.list.addPlaceholder')"
type="text"
v-focus
v-model="newTaskTitle"
ref="newTaskInput"
:style="{'height': `${textAreaHeight}px`}"
@keyup="errorMessage = ''"
@keydown.enter="handleEnter"
/>
<span class="icon is-small is-left">
<icon icon="tasks"/>
</span>
</p>
<p class="control">
<x-button
:disabled="newTaskTitle === '' || taskService.loading || null"
@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'
const INITIAL_SCROLL_HEIGHT = 40
const cleanupTitle = title => {
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
}
export default {
name: 'add-task',
data() {
return {
newTaskTitle: '',
taskService: new TaskService(),
errorMessage: '',
textAreaHeight: INITIAL_SCROLL_HEIGHT,
}
},
mixins: [
createTask,
],
components: {
QuickAddMagic,
},
props: {
defaultPosition: {
type: Number,
required: false,
},
},
watch: {
newTaskTitle(newVal) {
let scrollHeight = this.$refs.newTaskInput.scrollHeight
if (scrollHeight < INITIAL_SCROLL_HEIGHT || newVal === '') {
scrollHeight = INITIAL_SCROLL_HEIGHT
}
this.textAreaHeight = scrollHeight
},
},
methods: {
addTask() {
if (this.newTaskTitle === '') {
this.errorMessage = this.$t('list.create.addTitleRequired')
return
}
this.errorMessage = ''
if (this.taskService.loading) {
return
}
const newTasks = []
this.newTaskTitle.split(/[\r\n]+/).forEach(t => {
const title = cleanupTitle(t)
if (title === '') {
return
}
newTasks.push(
this.createNewTask(title, 0, this.$store.state.auth.settings.defaultListId, this.defaultPosition)
.then(task => {
this.$emit('taskAdded', task)
return task
}),
)
})
Promise.all(newTasks)
.then(() => {
this.newTaskTitle = ''
})
.catch(e => {
if (e === 'NO_LIST') {
this.errorMessage = this.$t('list.create.addListRequired')
return
}
this.$message.error(e)
})
},
handleEnter(e) {
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
// the new task(s). The vue event modifier don't allow this, hence this method.
if (e.shiftKey) {
return
}
e.preventDefault()
this.addTask()
},
},
}
</script>
<style lang="scss" scoped>
.task-add {
margin-bottom: 0;
.button {
height: 2.5rem;
}
}
.input, .textarea {
transition: border-color $transition;
}
</style>

View File

@ -1,14 +1,15 @@
<template>
<form @submit.prevent="editTaskSubmit()">
<div class="field">
<label class="label" for="tasktext">{{ $t('task.attributes.title') }}</label>
<label class="label" for="tasktext">Title</label>
<div class="control">
<input
:class="{ disabled: taskService.loading }"
:disabled="taskService.loading || null"
:disabled="taskService.loading"
@change="editTaskSubmit()"
class="input"
id="tasktext"
placeholder="The task text is here..."
type="text"
v-focus
v-model="taskEditTask.title"
@ -16,26 +17,26 @@
</div>
</div>
<div class="field">
<label class="label" for="taskdescription">{{ $t('task.attributes.description') }}</label>
<label class="label" for="taskdescription">Description</label>
<div class="control">
<editor
:preview-is-default="false"
id="taskdescription"
:placeholder="$t('task.description.placeholder')"
placeholder="The tasks description goes here..."
v-if="editorActive"
v-model="taskEditTask.description"
/>
</div>
</div>
<strong>{{ $t('task.attributes.reminders') }}</strong>
<strong>Reminders</strong>
<reminders
@change="editTaskSubmit()"
v-model="taskEditTask.reminderDates"
/>
<div class="field">
<label class="label">{{ $t('task.attributes.labels') }}</label>
<label class="label">Labels</label>
<div class="control">
<edit-labels
:task-id="taskEditTask.id"
@ -45,7 +46,7 @@
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.color') }}</label>
<label class="label">Color</label>
<div class="control">
<color-picker v-model="taskEditTask.hexColor" />
</div>
@ -56,14 +57,14 @@
class="is-fullwidth"
@click="editTaskSubmit()"
>
{{ $t('misc.save') }}
Save
</x-button>
<router-link
class="mt-2 has-text-centered is-block"
:to="{name: 'task.detail', params: {id: taskEditTask.id}}"
>
{{ $t('task.openDetail') }}
Open task detail view
</router-link>
</form>
</template>
@ -72,7 +73,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'
@ -84,15 +85,23 @@ export default {
data() {
return {
listId: this.$route.params.id,
listService: new ListService(),
taskService: new TaskService(),
listService: ListService,
taskService: TaskService,
priorities: priorities,
list: {},
editorActive: false,
newTask: new TaskModel(),
newTask: TaskModel,
isTaskEdit: false,
taskEditTask: TaskModel,
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
onOpen: this.updateLastReminderDate,
onClose: this.addReminderDate,
},
}
},
components: {
@ -100,7 +109,7 @@ export default {
Reminders,
EditLabels,
editor: () => ({
component: import('../../components/input/editor'),
component: import(/* webpackChunkName: "editor" */ '../../components/input/editor'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
@ -113,14 +122,18 @@ export default {
},
},
watch: {
task: {
handler() {
this.taskEditTask = this.task
this.initTaskFields()
},
immediate: true,
task() {
this.taskEditTask = this.task
this.initTaskFields()
},
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.newTask = new TaskModel()
this.taskEditTask = this.task
this.initTaskFields()
},
methods: {
initTaskFields() {
this.taskEditTask.dueDate =
@ -145,10 +158,10 @@ export default {
.then((r) => {
this.$set(this, 'taskEditTask', r)
this.initTaskFields()
this.$message.success({message: this.$t('task.detail.updateSuccess')})
this.success({message: 'The task has been saved successfully.'}, this)
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
},

View File

@ -7,15 +7,49 @@
type="secondary"
icon="filter"
>
{{ $t('filters.title') }}
Filters
</x-button>
</div>
<filter-popup
@change="loadTasks()"
@change="loadTasks"
:visible="showTaskFilter"
v-model="params"
/>
</div>
<g-gantt-chart
:chart-start="dateFrom.toString()"
:chart-end="dateTo.toString()"
:push-on-overlap="true"
row-label-width="0"
:row-height="37"
:grid="false"
@dragend-bar="dragged($event)"
:style="{'--day-width': dayWidthPercent + 'px'}"
ref="g-gantt-chart-container"
>
<g-gantt-row
v-for="(t, k) in ganttBars"
:key="t ? t.id : 'k'+k"
label=""
bar-start="start"
bar-end="end"
:bars="t.bars"
:highlight-on-hover="true"
>
<template v-slot:bar-label>
<span>
{{ t.title }}
</span>
<router-link :to="{name: 'task.detail', params: {id: t.id}}" class="edit-task-link">
<icon icon="pen"/>
</router-link>
</template>
</g-gantt-row>
</g-gantt-chart>
<div class="dates">
<template v-for="(y, yk) in days">
<div :key="yk + 'year'" class="months">
@ -24,10 +58,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"
@ -100,9 +142,8 @@
'has-super-high-priority':
t.priority === priorities.DO_NOW,
}"
>{{ t.title }}</span
>
{{ t.title }}
</span>
<priority-label :priority="t.priority"/>
<!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
<a @click="editTask(theTasks[k])" class="edit-toggle">
@ -141,7 +182,7 @@
@resizestop="resizeTask"
axis="x"
class="task nodate"
v-tooltip="$t('list.gantt.noDates')"
v-tooltip="'This task has no dates set.'"
>
<span>{{ t.title }}</span>
</VueDragResize>
@ -165,14 +206,14 @@
/>
</transition>
<x-button @click="showCreateNewTask" :shadow="false" icon="plus">
{{ $t('list.list.newTaskCta') }}
Add a new task
</x-button>
</form>
<transition name="fade">
<card
v-if="isTaskEdit"
class="taskedit"
:title="$t('list.list.editTask')"
title="Edit Task"
@close="() => {isTaskEdit = false;taskToEdit = null}"
:has-close="true"
>
@ -184,17 +225,30 @@
<script>
import VueDragResize from 'vue-drag-resize'
import {GGanttChart, GGanttRow} from 'vue-ganttastic'
import EditTask from './edit-task'
import {mapState} from 'vuex'
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'
import {colorIsDark} from '@/helpers/color/colorIsDark'
// Source: https://stackoverflow.com/a/11252167/10924593
const treatAsUTC = date => {
const result = new Date(date)
result.setMinutes(result.getMinutes() - result.getTimezoneOffset())
return result
}
const daysBetween = (startDate, endDate) => {
const millisecondsPerDay = 24 * 60 * 60 * 1000;
return (treatAsUTC(endDate) - treatAsUTC(startDate)) / millisecondsPerDay;
}
export default {
name: 'GanttChart',
@ -203,6 +257,8 @@ export default {
PriorityLabel,
EditTask,
VueDragResize,
GGanttChart,
GGanttRow,
},
props: {
listId: {
@ -232,19 +288,21 @@ export default {
endDate: null,
theTasks: [], // Pretty much a copy of the prop, since we cant mutate the prop directly
tasksWithoutDates: [],
taskService: new TaskService(),
taskService: TaskService,
taskDragged: null, // Saves to currently dragged task to be able to update it
fullWidth: 0,
now: new Date(),
now: null,
dayOffsetUntilToday: 0,
isTaskEdit: false,
taskToEdit: null,
newTaskTitle: '',
newTaskFieldActive: false,
priorities: priorities,
taskCollectionService: new TaskCollectionService(),
priorities: {},
taskCollectionService: TaskCollectionService,
showTaskFilter: false,
ganttBars: [],
params: {
sort_by: ['done', 'id'],
order_by: ['asc', 'desc'],
@ -260,12 +318,30 @@ export default {
dateTo: 'buildTheGanttChart',
listId: 'parseTasks',
},
created() {
this.now = new Date()
this.taskCollectionService = new TaskCollectionService()
this.taskService = new TaskService()
this.priorities = priorities
},
mounted() {
this.buildTheGanttChart()
},
computed: mapState({
canWrite: (state) => state.currentList.maxRight > Rights.READ,
}),
computed: {
...mapState({
canWrite: (state) => state.currentList.maxRight > Rights.READ,
}),
dayWidthPercent() {
const days = daysBetween(this.startDate, this.endDate)
let containerWidth = 1
if(this.$refs['g-gantt-chart-container']) {
containerWidth = this.$refs['g-gantt-chart-container'].$el.clientWidth
}
return Math.round(containerWidth / days)
},
},
methods: {
buildTheGanttChart() {
this.setDates()
@ -275,12 +351,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 (
@ -292,13 +368,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() {
@ -346,9 +424,28 @@ export default {
if (a.startDate > b.startDate) return 1
return 0
})
this.ganttBars = this.theTasks.map(t => {
return {
id: t.id,
title: t.title,
bars: [{
id: t.id,
start: t.startDate.toString(),
end: t.endDate.toString(),
label: t.title,
ganttBarConfig: {
color: colorIsDark(t.getHexColor()) ? 'black' : 'white',
backgroundColor: t.getHexColor(),
handles: true,
},
}],
}
})
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
addGantAttributes(t) {
@ -373,7 +470,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)
@ -382,7 +479,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
@ -425,7 +522,7 @@ export default {
this.$set(
this.theTasks,
tt,
this.addGantAttributes(r),
this.addGantAttributes(r)
)
break
}
@ -433,7 +530,7 @@ export default {
}
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
editTask(task) {
@ -470,11 +567,16 @@ export default {
this.hideCrateNewTask()
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
formatYear(date) {
return format(date, 'MMMM, yyyy')
dragged(e) {
const bar = e.movedBars.entries().next().value[0]
const index = this.theTasks.findIndex(t => t.id === bar.id)
const task = this.theTasks[index]
task.startDate = new Date(bar.start)
task.endDate = new Date(bar.end)
this.$set(this.theTasks, index, task)
},
},
}

View File

@ -0,0 +1,38 @@
import AttachmentModel from '../../../models/attachment'
import AttachmentService from '../../../services/attachment'
export default {
methods: {
attachmentUpload(file, onSuccess) {
const files = [file]
const attachmentService = new AttachmentService()
this.createAttachment(attachmentService, files, onSuccess)
},
createAttachment(attachmentService, files, onSuccess = () => {}) {
const attachmentModel = new AttachmentModel({taskId: this.taskId})
attachmentService.create(attachmentModel, files)
.then(r => {
console.debug(`Uploaded attachments for task ${this.taskId}, response was`, r)
if (r.success !== null) {
r.success.forEach(a => {
this.$store.dispatch('tasks/addTaskAttachment', {
taskId: this.taskId,
attachment: a,
})
onSuccess(`${window.API_URL}/tasks/${this.taskId}/attachments/${a.id}`)
})
}
if (r.errors !== null) {
r.errors.forEach(m => {
this.error(m)
})
}
})
.catch(e => {
this.error(e, this)
})
},
},
}

View File

@ -1,141 +0,0 @@
import {parseTaskText} from '@/modules/parseTaskText'
import TaskModel from '@/models/task'
import {formatISO} from 'date-fns'
import LabelTask from '@/models/labelTask'
import LabelModel from '@/models/label'
import LabelTaskService from '@/services/labelTask'
import {mapState} from 'vuex'
import UserService from '@/services/user'
import TaskService from '@/services/task'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
export default {
data() {
return {
taskService: TaskService,
labelTaskService: LabelTaskService,
userService: UserService,
}
},
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) {
const parsedTask = parseTaskText(newTaskTitle, getQuickAddMagicMode())
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')
}
// Separate closure because we need to wait for the results of the user search if users were entered in the
// task create request. Because _that_ happens in a promise, we'll need something to call when it resolves.
const createTask = () => {
const task = new TaskModel({
title: parsedTask.text,
listId: listId,
dueDate: parsedTask.date !== null ? formatISO(parsedTask.date) : null, // I don't know why, but it all goes up in flames when I just pass in the date normally.
priority: parsedTask.priority,
assignees: assignees,
bucketId: bucketId,
position: position,
})
return this.taskService.create(task)
.then(task => {
if (parsedTask.labels.length > 0) {
const labelAddsToWaitFor = []
const addLabelToTask = label => {
const labelTask = new LabelTask({
taskId: task.id,
labelId: label.id,
})
return this.labelTaskService.create(labelTask)
.then(result => {
task.labels.push(label)
return Promise.resolve(result)
})
.catch(e => Promise.reject(e))
}
// Then do everything that is involved in finding, creating and adding the label to the task
parsedTask.labels.forEach(labelTitle => {
// Check if the label exists
const label = Object.values(this.labels).find(l => {
return l.title.toLowerCase() === labelTitle.toLowerCase()
})
// Label found, use it
if (typeof label !== 'undefined') {
labelAddsToWaitFor.push(addLabelToTask(label))
} else {
// label not found, create it
const label = new LabelModel({title: labelTitle})
labelAddsToWaitFor.push(this.$store.dispatch('labels/createLabel', label)
.then(res => {
return addLabelToTask(res)
})
.catch(e => Promise.reject(e)),
)
}
})
// This waits until all labels are created and added to the task
return Promise.all(labelAddsToWaitFor)
.then(() => {
return Promise.resolve(task)
})
}
return Promise.resolve(task)
})
.catch(e => Promise.reject(e))
}
if (parsedTask.assignees.length > 0) {
const searches = []
parsedTask.assignees.forEach(a => {
searches.push(this.userService.getAll({}, {s: a})
.then(users => {
const user = users.find(u => u.username.toLowerCase() === a.toLowerCase())
if (typeof user !== 'undefined') {
assignees.push(user)
}
return Promise.resolve(users)
}),
)
})
return Promise.all(searches)
.then(() => createTask())
}
return createTask()
},
},
}

View File

@ -1,15 +1,5 @@
import TaskCollectionService from '@/services/taskCollection'
import cloneDeep from 'lodash/cloneDeep'
// 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',
}
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.
@ -17,41 +7,51 @@ const DEFAULT_PARAMS = {
export default {
data() {
return {
taskCollectionService: new TaskCollectionService(),
taskCollectionService: TaskCollectionService,
tasks: [],
pages: [],
currentPage: 0,
loadedList: null,
showTaskSearch: false,
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
},
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
}
@ -68,28 +68,56 @@ export default {
const currentList = {
id: list.listId,
params,
search,
page,
params: params,
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.$message.error(e)
this.error(e, this)
})
},
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)
@ -102,9 +130,53 @@ 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
}
return this.tasks.sort(function (a, b) {
if (a.done < b.done)
return -1
if (a.done > b.done)
return 1
if (a.id > b.id)
return -1
if (a.id < b.id)
return 1
return 0
})
},
searchTasks() {
// Only search if the search term changed
if (this.$route.query === this.searchTerm) {
return
}
this.$router.push({
name: 'list.list',
query: {search: this.searchTerm},
})
},
hideSearchBar() {
// This is a workaround.
// When clicking on the search button, @blur from the input is fired. If we
// would then directly hide the whole search bar directly, no click event
// from the button gets fired. To prevent this, we wait 200ms until we hide
// everything so the button has a chance of firering the search event.
setTimeout(() => {
this.showTaskSearch = false
}, 200)
},
getRouteForPagination(page = 1, type = 'list') {
return {
name: 'list.' + type,
params: {
type: type,
},
query: {
page: page,
},
}
},
},

View File

@ -4,11 +4,11 @@
<span class="icon is-grey">
<icon icon="paperclip"/>
</span>
{{ $t('task.attachment.title') }}
Attachments
</h3>
<input
:disabled="attachmentService.loading || null"
:disabled="attachmentService.loading"
@change="uploadNewAttachment()"
id="files"
multiple
@ -35,16 +35,18 @@
<div class="filename">{{ a.file.name }}</div>
<div class="info">
<p class="collapses">
<i18n path="task.attachment.createdBy">
<span v-tooltip="formatDate(a.created)">
{{ formatDateSince(a.created) }}
</span>
<span>
created
<span v-tooltip="formatDate(a.created)">{{
formatDateSince(a.created)
}}</span>
by
<user
:avatar-size="24"
:user="a.createdBy"
:is-inline="true"
/>
</i18n>
</span>
<span>
{{ a.file.getHumanSize() }}
</span>
@ -55,22 +57,16 @@
<p>
<a
@click.prevent.stop="downloadAttachment(a)"
v-tooltip="$t('task.attachment.downloadTooltip')"
v-tooltip="'Download this attachment'"
>
{{ $t('misc.download') }}
</a>
<a
@click.stop="copyUrl(a)"
v-tooltip="$t('task.attachment.copyUrlTooltip')"
>
{{ $t('task.attachment.copyUrl') }}
Download
</a>
<a
@click.prevent.stop="() => {attachmentToDelete = a; showDeleteModal = true}"
v-if="editEnabled"
v-tooltip="$t('task.attachment.deleteTooltip')"
v-tooltip="'Delete this attachment'"
>
{{ $t('misc.delete') }}
Delete
</a>
</p>
</div>
@ -86,7 +82,7 @@
type="secondary"
:shadow="false"
>
{{ $t('task.attachment.upload') }}
Upload attachment
</x-button>
<!-- Dropzone -->
@ -99,7 +95,7 @@
<div class="icon">
<icon icon="cloud-upload-alt"/>
</div>
<div class="hint">{{ $t('task.attachment.drop') }}</div>
<div class="hint">Drop files here to upload</div>
</div>
</div>
@ -110,12 +106,12 @@
v-if="showDeleteModal"
@submit="deleteAttachment()"
>
<template #header><span>{{ $t('task.attachment.delete') }}</span></template>
<template #text>
<p>{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br/>
<strong>{{ $t('task.attachment.deleteText2') }}</strong></p>
</template>
<span slot="header">Delete attachment</span>
<p slot="text">
Are you sure you want to delete the attachment
{{ attachmentToDelete.file.name }}?<br/>
<b>This CANNOT BE UNDONE!</b>
</p>
</modal>
</transition>
@ -139,19 +135,20 @@
import AttachmentService from '../../../services/attachment'
import AttachmentModel from '../../../models/attachment'
import User from '../../misc/user'
import attachmentUpload from '@/components/tasks/mixins/attachmentUpload'
import {mapState} from 'vuex'
import copy from 'copy-to-clipboard'
import { uploadFiles, generateAttachmentUrl } from '@/helpers/attachments'
export default {
name: 'attachments',
components: {
User,
},
mixins: [
attachmentUpload,
],
data() {
return {
attachmentService: new AttachmentService(),
attachmentService: AttachmentService,
showDropzone: false,
showDeleteModal: false,
@ -173,6 +170,9 @@ export default {
default: true,
},
},
created() {
this.attachmentService = new AttachmentService()
},
computed: mapState({
attachments: (state) => state.attachments.attachments,
}),
@ -216,7 +216,7 @@ export default {
this.uploadFiles(this.$refs.files.files)
},
uploadFiles(files) {
uploadFiles(this.attachmentService, this.taskId, files)
this.createAttachment(this.attachmentService, files)
},
deleteAttachment() {
this.attachmentService
@ -224,12 +224,12 @@ export default {
.then((r) => {
this.$store.commit(
'attachments/removeById',
this.attachmentToDelete.id,
this.attachmentToDelete.id
)
this.$message.success(r)
this.success(r, this)
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
@ -250,9 +250,6 @@ export default {
this.downloadAttachment(attachment)
}
},
copyUrl(attachment) {
copy(generateAttachmentUrl(this.taskId, attachment.id))
},
},
}
</script>

View File

@ -1,60 +0,0 @@
<template>
<span v-if="checklist.total > 0" class="checklist-summary">
<svg width="12" height="12">
<circle stroke-width="2" fill="transparent" cx="50%" cy="50%" r="5"></circle>
<circle stroke-width="2" stroke-dasharray="31" :stroke-dashoffset="checklistCircleDone"
stroke-linecap="round" fill="transparent" cx="50%" cy="50%" r="5"></circle>
</svg>
<span>
{{ $t(checklist.total === checklist.checked ? 'task.checklistAllDone' : 'task.checklistTotal', checklist) }}
</span>
</span>
</template>
<script>
import {getChecklistStatistics} from '../../../helpers/checklistFromText'
export default {
name: 'checklist-summary',
props: {
task: {
required: true,
},
},
computed: {
checklist() {
return getChecklistStatistics(this.task.description)
},
checklistCircleDone() {
const r = 5
const c = Math.PI * (r * 2)
const progress = this.checklist.checked / this.checklist.total * 100
return ((100 - progress) / 100) * c
},
},
}
</script>
<style scoped lang="scss">
.checklist-summary {
color: $grey-500;
display: inline-flex;
align-items: center;
svg {
transform: rotate(-90deg);
transition: stroke-dashoffset 0.35s;
margin-right: .25rem;
circle {
stroke: $grey-400;
&:last-child {
stroke: $primary;
}
}
}
}
</style>

View File

@ -1,18 +1,20 @@
<template>
<div class="content details" v-if="enabled">
<div class="content details">
<h3 v-if="canWrite || comments.length > 0">
<span class="icon is-grey">
<icon :icon="['far', 'comments']"/>
</span>
{{ $t('task.comment.title') }}
Comments
</h3>
<div class="comments">
<span
class="is-inline-flex is-align-items-center"
v-if="taskCommentService.loading && saving === null && !creating"
v-if="
taskCommentService.loading && saving === null && !creating
"
>
<span class="loader is-inline-block mr-2"></span>
{{ $t('task.comment.loading') }}
Loading comments...
</span>
<div :key="c.id" class="media comment" v-for="c in comments">
<figure class="media-left is-hidden-mobile">
@ -33,7 +35,8 @@
height="20"
width="20"
/>
<strong>{{ c.author.getDisplayName() }}</strong>&nbsp;
<strong>{{ c.author.getDisplayName() }}</strong
>&nbsp;
<span v-tooltip="formatDate(c.created)" class="has-text-grey">
{{ formatDateSince(c.created) }}
</span>
@ -41,7 +44,7 @@
v-if="+new Date(c.created) !== +new Date(c.updated)"
v-tooltip="formatDate(c.updated)"
>
· {{ $t('task.comment.edited', {date: formatDateSince(c.updated)}) }}
· edited {{ formatDateSince(c.updated) }}
</span>
<transition name="fade">
<span
@ -51,17 +54,19 @@
saving === c.id
"
>
<span class="loader is-inline-block mr-2"></span>
{{ $t('misc.saving') }}
<span
class="loader is-inline-block mr-2"
></span>
Saving...
</span>
<span
class="has-text-success"
v-else-if="
v-if="
!taskCommentService.loading &&
saved === c.id
"
>
{{ $t('misc.saved') }}
Saved!
</span>
</transition>
</div>
@ -77,8 +82,8 @@
}
"
v-model="c.comment"
:has-edit-bottom="true"
:bottom-actions="actions[c.id]"
:show-save="true"
/>
</div>
</div>
@ -99,8 +104,10 @@
class="is-inline-flex"
v-if="taskCommentService.loading && creating"
>
<span class="loader is-inline-block mr-2"></span>
{{ $t('task.comment.creating') }}
<span
class="loader is-inline-block mr-2"
></span>
Creating comment...
</span>
</transition>
<div class="field">
@ -113,18 +120,20 @@
:has-preview="false"
:upload-callback="attachmentUpload"
:upload-enabled="true"
:placeholder="$t('task.comment.placeholder')"
placeholder="Add your comment..."
v-if="editorActive"
v-model="newComment.comment"
/>
</div>
<div class="field">
<x-button
:loading="taskCommentService.loading && !isCommentEdit"
:loading="
taskCommentService.loading && !isCommentEdit
"
:disabled="newComment.comment === ''"
@click="addComment()"
>
{{ $t('task.comment.comment') }}
Comment
</x-button>
</div>
</div>
@ -138,14 +147,11 @@
@submit="deleteComment()"
v-if="showDeleteModal"
>
<template #header><span>{{ $t('task.comment.delete') }}</span></template>
<template #text>
<p>
{{ $t('task.comment.deleteText1') }}<br/>
<strong>{{ $t('task.comment.deleteText2') }}</strong>
</p>
</template>
<span slot="header">Delete this comment</span>
<p slot="text">
Are you sure you want to delete this comment? <br/>This
<b>CANNOT BE UNDONE!</b>
</p>
</modal>
</transition>
</div>
@ -154,21 +160,21 @@
<script>
import TaskCommentService from '../../../services/taskComment'
import TaskCommentModel from '../../../models/taskComment'
import attachmentUpload from '../mixins/attachmentUpload'
import LoadingComponent from '../../misc/loading'
import ErrorComponent from '../../misc/error'
import {uploadFile} from '@/helpers/attachments'
import {mapState} from 'vuex'
export default {
name: 'comments',
components: {
editor: () => ({
component: import('../../input/editor'),
component: import(/* webpackChunkName: "editor" */ '../../input/editor'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
},
mixins: [attachmentUpload],
props: {
taskId: {
type: Number,
@ -183,13 +189,13 @@ export default {
comments: [],
showDeleteModal: false,
commentToDelete: new TaskCommentModel(),
commentToDelete: TaskCommentModel,
isCommentEdit: false,
commentEdit: new TaskCommentModel(),
commentEdit: TaskCommentModel,
taskCommentService: new TaskCommentService(),
newComment: new TaskCommentModel(),
taskCommentService: TaskCommentService,
newComment: TaskCommentModel,
editorActive: true,
actions: {},
@ -198,33 +204,30 @@ export default {
creating: false,
}
},
created() {
this.taskCommentService = new TaskCommentService()
this.newComment = new TaskCommentModel({taskId: this.taskId})
this.commentEdit = new TaskCommentModel({taskId: this.taskId})
this.commentToDelete = new TaskCommentModel({taskId: this.taskId})
this.comments = []
},
mounted() {
this.loadComments()
},
watch: {
taskId: {
handler(taskId) {
if (!this.enabled) {
return
}
this.loadComments()
this.newComment.taskId = taskId
this.commentEdit.taskId = taskId
this.commentToDelete.taskId = taskId
},
immediate: true,
taskId() {
this.loadComments()
},
canWrite() {
this.makeActions()
},
},
computed: mapState({
userAvatar: state => state.auth.info.getAvatarUrl(48),
enabled: state => state.config.taskCommentsEnabled,
}),
methods: {
attachmentUpload(...args) {
return uploadFile(this.taskId, ...args)
computed: {
userAvatar() {
return this.$store.state.auth.info.getAvatarUrl(48)
},
},
methods: {
loadComments() {
this.taskCommentService
.getAll({taskId: this.taskId})
@ -233,7 +236,7 @@ export default {
this.makeActions()
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
},
addComment() {
@ -255,11 +258,13 @@ export default {
.then((r) => {
this.comments.push(r)
this.newComment.comment = ''
this.$message.success({message: this.$t('task.comment.addedSuccess')})
this.makeActions()
this.success(
{message: 'The comment was added successfully.'},
this
)
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
.finally(() => {
this.creating = false
@ -295,7 +300,7 @@ export default {
}, 2000)
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
.finally(() => {
this.isCommentEdit = false
@ -313,7 +318,7 @@ export default {
}
})
.catch((e) => {
this.$message.error(e)
this.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
@ -325,7 +330,7 @@ export default {
this.$set(this.actions, c.id, [
{
action: () => this.toggleDelete(c.id),
title: this.$t('misc.delete'),
title: 'Remove',
},
])
})

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