Compare commits

..

2 commits

Author SHA1 Message Date
kolaente
26cdf94740
chore: update workbox version const 2022-08-02 13:28:49 +02:00
renovate
43d9a67725 chore(deps): update workbox monorepo to v6.5.4 2022-07-31 15:03:01 +00:00
347 changed files with 19840 additions and 25511 deletions

View file

@ -29,91 +29,26 @@ steps:
# AWS_SECRET_ACCESS_KEY:
# from_secret: cache_aws_secret_access_key
# settings:
# debug: true
# 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 "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
- name: dependencies
image: node:18-alpine
image: node:18
pull: true
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000
- yarn --frozen-lockfile --network-timeout 100000
# depends_on:
# - restore-cache
- name: lint
image: node:18-alpine
pull: true
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run lint
depends_on:
- dependencies
- name: build-prod
image: node:18-alpine
pull: true
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run build
depends_on:
- dependencies
- name: test-unit
image: node:18-alpine
pull: true
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run test:unit
depends_on:
- dependencies
- name: typecheck
failure: ignore
image: node:18-alpine
pull: true
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run typecheck
depends_on:
- dependencies
- name: test-frontend
image: cypress/browsers:node16.14.0-chrome99-ff97
pull: true
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
CYPRESS_RECORD_KEY:
from_secret: cypress_project_key
commands:
- sed -i 's/localhost/api/g' dist/index.html
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm cypress install
- pnpm run serve:dist & npx wait-on http://localhost:4173
- pnpm run test:frontend --browser chrome --record
depends_on:
- build-prod
# - name: rebuild-cache
# image: meltwater/drone-cache:dev
# pull: true
@ -128,14 +63,70 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
# depends_on:
# - dependencies
- name: lint
image: node:18
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- yarn run lint
depends_on:
- dependencies
- name: build-prod
image: node:18
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- yarn build
depends_on:
- dependencies
- name: test-unit
image: node:18
pull: true
commands:
- yarn test:unit
depends_on:
- dependencies
- name: typecheck
failure: ignore
image: node:18
pull: true
commands:
- yarn typecheck
depends_on:
- dependencies
- name: test-frontend
image: cypress/browsers:node16.5.0-chrome94-ff93
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_RECORD_KEY:
from_secret: cypress_project_key
commands:
- sed -i 's/localhost/api/g' dist/index.html
- yarn serve:dist & npx wait-on http://localhost:4173
- yarn test:frontend --browser chrome --record
depends_on:
- build-prod
- name: deploy-preview
image: node:18-alpine
image: node:18
pull: true
environment:
NETLIFY_AUTH_TOKEN:
@ -147,8 +138,7 @@ steps:
commands:
- cp -r dist dist-preview
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
- sed -i 's|localhost:3456|try.vikunja.io|g' dist-preview/index.html
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js
depends_on:
@ -191,22 +181,21 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
- name: build
image: node:18-alpine
image: node:18
pull: true
group: build-static
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- corepack enable && pnpm config set store-dir .cache/.pnp
- pnpm install --fetch-timeout 100000
- pnpm run lint
- yarn --frozen-lockfile --network-timeout 100000
- yarn run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- pnpm run build
- 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
@ -267,22 +256,21 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
- name: build
image: node:18-alpine
image: node:18
pull: true
group: build-static
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000
- pnpm run lint
- yarn --frozen-lockfile --network-timeout 100000
- yarn run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- pnpm run build
- 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
@ -659,6 +647,6 @@ steps:
from_secret: crowdin_key
---
kind: signature
hmac: c885a0e50db729842402494aa645dd3ac662828b691108550f6bf302158295ba
hmac: 997e1badebe484ac29557c4af356e63db4d3d57f3d32e92d482f117f8cec64da
...

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -1,6 +1,3 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
'root': true,
'env': {
@ -12,7 +9,7 @@ module.exports = {
'extends': [
'eslint:recommended',
'plugin:vue/vue3-essential',
'@vue/eslint-config-typescript/recommended',
'@vue/typescript',
],
'rules': {
'vue/html-quotes': [
@ -31,6 +28,7 @@ module.exports = {
'error',
'never',
],
'vue/script-setup-uses-vars': 'error',
// see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese)
'no-unused-vars': 'off',
@ -42,7 +40,6 @@ module.exports = {
'parserOptions': {
'parser': '@typescript-eslint/parser',
'ecmaVersion': 2022,
'sourceType': 'module',
},
'ignorePatterns': [
'*.test.*',

View file

@ -1,58 +0,0 @@
name: Bug Report
description: Found something you weren't expecting? Report it here!
labels: kind/bug
body:
- type: markdown
attributes:
value: |
NOTE: If your issue is a security concern, please send an email to security@vikunja.io instead of opening a public issue.
- type: markdown
attributes:
value: |
Please fill out this issue template to report a bug.
1. If you want to propose a new feature, please open a discussion thread in the forum: https://community.vikunja.io
2. Please ask questions or configuration/deploy problems on our [Matrix Room](https://matrix.to/#/#vikunja:matrix.org) or forum (https://community.vikunja.io).
3. Make sure you are using the latest release and
take a moment to check that your issue hasn't been reported before.
4. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report and closed.
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below).
- type: input
id: frontend-version
attributes:
label: Vikunja Frontend Version
description: Vikunja frontend version (or commit reference) of your instance
validations:
required: true
- type: input
id: api-version
attributes:
label: Vikunja API Version
description: Vikunja API version (or commit reference) of your instance
validations:
required: true
- type: input
id: browser-version
attributes:
label: Browser and version
description: If your issue is related to a frontend problem, please provide the browser and version you used to reproduce it.
- type: dropdown
id: can-reproduce
attributes:
label: Can you reproduce the bug on the Vikunja demo site?
options:
- "Yes"
- "No"
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If this issue involves the Web Interface, please provide one or more screenshots

View file

@ -1,17 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: API issues
url: https://code.vikunja.io/api/issues
about: This is the frontend repo. Please open api-related bug reports and discussions in the api 0repo. Not sure if your issue is frontend or api? Ask in Matrix or the forum first.
- name: Forum
url: https://community.vikunja.io/
about: Feature Requests, Questions, configuration or deployment problems should be discussed in the forum.
- name: Security-related issues
url: https://vikunja.io/contact/#security
about: For security concerns, please send a mail to security@vikunja.io instead of opening a public issue.
- name: Chat on Matrix
url: https://matrix.to/#/#vikunja:matrix.org
about: Please ask any quick questions here.
- name: Translations
url: https://crowdin.com/project/vikunja
about: Any problems or requests for new languages about translations should be handled in crowdin.

5
.gitignore vendored
View file

@ -2,21 +2,16 @@
node_modules
/dist*
*.zip
.direnv/
# local env files
.env.local
.env.*.local
# Log files
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
stats.html
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files
.idea

2
.npmrc
View file

@ -1,2 +0,0 @@
auto-install-peers=true
fetch-timeout=100000

View file

@ -1,5 +1,5 @@
{
"eslint.packageManager": "pnpm",
"eslint.packageManager": "yarn",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": true

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,28 @@
# Stage 1: Build application
FROM node:18-alpine AS compile-image
FROM node:18 AS compile-image
WORKDIR /build
ARG USE_RELEASE=false
ARG RELEASE_VERSION=main
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ADD . ./
ENV YARN_CACHE_FOLDER .cache/yarn/
COPY . ./
RUN \
if [ $USE_RELEASE = true ]; then \
rm -rf dist/ && \
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \
exit 0; \
fi && \
# https://pnpm.io/installation#using-corepack
corepack enable && \
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
# Build the frontend
pnpm install && \
apk add --no-cache git && \
yarn install --frozen-lockfile --network-timeout 100000 && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
pnpm run build
yarn run build
# Stage 2: copy
FROM nginx:alpine
FROM nginx
COPY nginx.conf /etc/nginx/nginx.conf
COPY run.sh /run.sh
@ -40,10 +36,4 @@ ENV PGID 1000
LABEL maintainer="maintainers@vikunja.io"
RUN apk add --no-cache \
# for sh file
bash \
# installs usermod and groupmod
shadow
CMD "/run.sh"

View file

@ -4,7 +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.19.1-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.18.2-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js.
@ -22,27 +22,23 @@ There is a [docker image available](https://hub.docker.com/r/vikunja/api) with s
## Project setup
```shell
pnpm install
yarn install
```
### Compiles and hot-reloads for development
```shell
pnpm run serve
yarn run serve
```
### Compiles and minifies for production
```shell
pnpm run build
yarn run build
```
### Lints and fixes files
```shell
pnpm run lint
yarn run lint
```
## Sponsors
[![Relm](https://vikunja.io/images/sponsors/relm.png)](https://relm.us)

View file

@ -1,59 +0,0 @@
[changelog]
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits
| filter(attribute="scope")
| sort(attribute="scope") %}
* *({{commit.scope}})* {{ commit.message | upper_first }}
{%- if commit.breaking %}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{%- endif -%}
{%- endfor -%}
{%- for commit in commits %}
{%- if commit.scope -%}
{% else -%}
* {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ commit.id }}))
{% if commit.breaking -%}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{% endif -%}
{% endif -%}
{% endfor -%}
{% raw %}\n{% endraw %}\
{% endfor %}\n
"""
#{% for group, commits in commits | group_by(attribute="group") %}
# ### {{ group | upper_first }}
# {% for commit in commits %}\
# - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ commit.id }}))
# {% endfor %}\
#{% endfor %}\n
# remove the leading and trailing whitespace from the template
trim = true
[git]
conventional_commits = true
filter_unconventional = false
commit_parsers = [
{ message = ".*(deps).*", group = "Dependencies"},
{ message = "^feat", group = "Features"},
{ message = "^fix", group = "Bug Fixes"},
{ message = "^doc", group = "Documentation"},
{ message = "^perf", group = "Performance"},
{ message = "^refactor", group = "Refactor"},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore\\(release\\): prepare for", skip = true},
{ message = "^chore", group = "Miscellaneous Tasks"},
{ body = ".*security", group = "Security"},
{ message = ".*", group = "Other", default_scope = "other"}, # Everything that's not a conventional commit goes into the "Other" category
]

View file

@ -36,7 +36,7 @@ to get a shell inside the cypress container.
In that shell you can then execute the tests with
```shell
pnpm run test:frontend
yarn test:frontend
```
### Using The Cypress Dashboard
@ -44,5 +44,5 @@ pnpm run test:frontend
To open the Cypress Dashboard and run tests from there, run
```shell
pnpm run cypress:open
yarn cypress:open
```

View file

@ -9,7 +9,7 @@ services:
ports:
- 3456:3456
cypress:
image: cypress/browsers:node16.14.0-chrome99-ff97
image: cypress/browsers:node12.18.3-chrome87-ff82
volumes:
- ..:/project
- $HOME/.cache:/home/node/.cache/

View file

@ -16,12 +16,10 @@ describe('List View Gantt', () => {
})
it('Shows tasks from the current and next month', () => {
const now = Date.UTC(2022, 8, 25)
cy.clock(now, ['Date'])
const nextMonth = new Date(now)
const now = new Date()
const nextMonth = now
nextMonth.setDate(1)
nextMonth.setMonth(9)
nextMonth.setMonth(now.getMonth() + 1)
cy.visit('/lists/1/gantt')
@ -34,7 +32,7 @@ describe('List View Gantt', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
@ -65,7 +63,7 @@ describe('List View Gantt', () => {
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')

View file

@ -59,7 +59,7 @@ describe('Lists', () => {
.click()
cy.get('#title')
.type(`{selectall}${newListName}`)
cy.get('footer.card-footer .button')
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()

View file

@ -63,7 +63,7 @@ describe('Namepaces', () => {
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
cy.get('#namespacetext')
.type(`{selectall}${newNamespaceName}`)
cy.get('footer.card-footer .button')
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()

View file

@ -3,19 +3,14 @@ import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function createLists() {
UserFactory.create(1)
NamespaceFactory.create(1)
const lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
return lists
}
export function prepareLists(setLists = () => {}) {
beforeEach(() => {
const lists = createLists()
UserFactory.create(1)
NamespaceFactory.create(1)
const lists = ListFactory.create(1, {
title: 'First List'
})
setLists(lists)
TaskFactory.truncate()
})
}

View file

@ -12,51 +12,15 @@ import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
import {TaskAttachmentFactory} from '../../factories/task_attachments'
function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button')
.contains('Add Labels')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(labelTitle)
cy.get('.task-view .details.labels-list .multiselect .search-results')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 4000 })
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.should('exist')
.should('contain', labelTitle)
}
function uploadAttachmentAndVerify(taskId: number) {
cy.intercept(`${Cypress.env('API_URL')}/tasks/${taskId}/attachments`).as('uploadAttachment')
cy.get('.task-view .action-buttons .button')
.contains('Add Attachments')
.click()
cy.get('input[type=file]', {timeout: 1000})
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.wait('@uploadAttachment')
cy.get('.attachments .attachments .files a.attachment')
.should('exist')
}
describe('Task', () => {
let namespaces
let lists
let buckets
beforeEach(() => {
UserFactory.create(1)
namespaces = NamespaceFactory.create(1)
lists = ListFactory.create(1)
buckets = BucketFactory.create(1, {
list_id: lists[0].id,
})
TaskFactory.truncate()
UserListFactory.truncate()
})
@ -116,7 +80,6 @@ describe('Task', () => {
describe('Task Detail View', () => {
beforeEach(() => {
TaskCommentFactory.truncate()
LabelTaskFactory.truncate()
})
it('Shows all task details', () => {
@ -381,31 +344,21 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
addLabelToTaskAndVerify(labels[0].title)
})
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
cy.get('.task-view .action-buttons .button')
.contains('Add Labels')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(labels[0].title)
cy.get('.task-view .details.labels-list .multiselect .search-results')
.children()
.first()
.click()
addLabelToTaskAndVerify(labels[0].title)
cy.get('.modal-content .close')
.click()
cy.get('.bucket .task')
.should('contain.text', labels[0].title)
cy.get('.global-notification', { timeout: 4000 })
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.should('exist')
.should('contain', labels[0].title)
})
it('Can remove a label from a task', () => {
@ -464,87 +417,5 @@ describe('Task', () => {
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can set a priority for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Priority')
.click()
cy.get('.task-view .columns.details .column')
.contains('Priority')
.get('.select select')
.select('Urgent')
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.task-view .columns.details .column')
.contains('Priority')
.get('.select select')
.should('have.value', '4')
})
it('Can set the progress for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Progress')
.click()
cy.get('.task-view .columns.details .column')
.contains('Progress')
.get('.select select')
.select('50%')
cy.get('.global-notification')
.should('contain', 'Success')
cy.wait(200)
cy.get('.task-view .columns.details .column')
.contains('Progress')
.get('.select select')
.should('be.visible')
.should('have.value', '0.5')
})
it('Can add an attachment to a task', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
uploadAttachmentAndVerify(tasks[0].id)
})
it('Can add an attachment to a task and see it appearing on kanban', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
.click()
uploadAttachmentAndVerify(tasks[0].id)
cy.get('.modal-content .close')
.click()
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
.should('exist')
})
})
})

View file

@ -1,44 +1,16 @@
import '../../support/authenticateUser'
import {createLists} from '../list/prepareLists'
function logout() {
cy.get('.navbar .user .username')
.click()
cy.get('.navbar .user .dropdown-menu .dropdown-item')
.contains('Logout')
.click()
}
describe('Log out', () => {
it('Logs the user out', () => {
cy.visit('/')
expect(localStorage.getItem('token')).to.not.eq(null)
logout()
cy.get('.navbar .user .username')
.click()
cy.get('.navbar .user .dropdown-menu .dropdown-item')
.contains('Logout')
.click()
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('token')).to.eq(null)
})
})
it.skip('Should clear the list history after logging the user out', () => {
const lists = createLists()
cy.visit(`/lists/${lists[0].id}`)
.then(() => {
expect(localStorage.getItem('listHistory')).to.not.eq(null)
})
logout()
cy.wait(1000) // This makes re-loading of the list and associated entities (and the resulting error) visible
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('listHistory')).to.eq(null)
})
})
})

View file

@ -1,17 +0,0 @@
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class TaskAttachmentFactory extends Factory {
static table = 'task_attachments'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: 1,
file_id: 1,
created: formatISO(now),
}
}
}

View file

@ -1,25 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1664753041,
"narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,10 +0,0 @@
{
description = "Vikunja frontend dev environment";
outputs = { self, nixpkgs }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
defaultPackage.x86_64-linux =
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.pnpm pkgs.cypress ]; };
};
}

View file

@ -1,9 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<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"/>

View file

@ -1,5 +1,5 @@
[build]
command = "pnpm run build"
command = "yarn build"
publish = "dist-preview"
[[redirects]]

View file

@ -11,96 +11,88 @@
"build:dev": "vite build -m development --outDir dist-dev/",
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
"cypress:open": "cypress open",
"test:unit": "vitest --run",
"test:unit": "vitest",
"test:unit-watch": "vitest watch",
"test:frontend": "cypress run",
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"browserslist:update": "npx browserslist@latest --update-db"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.2.0",
"@fortawesome/free-regular-svg-icons": "6.2.0",
"@fortawesome/free-solid-svg-icons": "6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1",
"@github/hotkey": "2.0.1",
"@kyvg/vue3-notification": "2.4.1",
"@sentry/tracing": "7.15.0",
"@sentry/vue": "7.15.0",
"@kyvg/vue3-notification": "2.3.5",
"@sentry/tracing": "7.7.0",
"@sentry/vue": "7.7.0",
"@types/is-touch-device": "1.0.0",
"@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0",
"@vueuse/core": "9.3.0",
"@vueuse/router": "9.3.0",
"axios": "0.27.2",
"blurhash": "2.0.3",
"@types/sortablejs": "1.13.0",
"@vueuse/core": "8.9.4",
"@vueuse/router": "8.9.4",
"blurhash": "1.1.5",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.65.9",
"date-fns": "2.29.3",
"dompurify": "2.4.0",
"easymde": "2.18.0",
"date-fns": "2.29.1",
"dompurify": "2.3.10",
"easymde": "2.16.1",
"flatpickr": "4.6.13",
"flexsearch": "0.7.21",
"floating-vue": "2.0.0-beta.20",
"highlight.js": "11.6.0",
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"marked": "4.1.1",
"minimist": "1.2.7",
"pinia": "2.0.23",
"marked": "4.0.18",
"minimist": "1.2.6",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"sortablejs": "1.15.0",
"ufo": "0.8.5",
"vue": "3.2.40",
"vue-advanced-cropper": "2.8.6",
"v-tooltip": "4.0.0-beta.17",
"vue": "3.2.37",
"vue-advanced-cropper": "2.8.3",
"vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.8",
"vue-i18n": "9.2.2",
"vue-router": "4.1.5",
"vue-flatpickr-component": "9.0.6",
"vue-i18n": "9.2.0-beta.40",
"vue-router": "4.1.2",
"vuex": "4.0.2",
"workbox-precaching": "6.5.4",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.2.1",
"@cypress/vite-dev-server": "3.3.1",
"@cypress/vue": "4.2.0",
"@faker-js/faker": "7.5.0",
"@rushstack/eslint-patch": "1.2.0",
"@types/dompurify": "2.3.4",
"@cypress/vite-dev-server": "3.0.0",
"@cypress/vue": "4.0.0",
"@faker-js/faker": "7.3.0",
"@fortawesome/fontawesome-svg-core": "6.1.2",
"@fortawesome/free-regular-svg-icons": "6.1.2",
"@fortawesome/free-solid-svg-icons": "6.1.2",
"@fortawesome/vue-fontawesome": "3.0.1",
"@types/flexsearch": "0.7.3",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.7",
"@types/node": "16.11.65",
"@typescript-eslint/eslint-plugin": "5.40.0",
"@typescript-eslint/parser": "5.40.0",
"@vitejs/plugin-legacy": "2.2.0",
"@vitejs/plugin-vue": "3.1.2",
"@vue/eslint-config-typescript": "11.0.2",
"@vue/test-utils": "2.1.0",
"@typescript-eslint/eslint-plugin": "5.30.7",
"@typescript-eslint/parser": "5.30.7",
"@vitejs/plugin-legacy": "2.0.0",
"@vitejs/plugin-vue": "3.0.1",
"@vue/eslint-config-typescript": "11.0.0",
"@vue/test-utils": "2.0.2",
"@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.12",
"browserslist": "4.21.4",
"caniuse-lite": "1.0.30001418",
"cypress": "10.10.0",
"esbuild": "0.15.10",
"eslint": "8.25.0",
"eslint-plugin-vue": "9.6.0",
"express": "4.18.2",
"happy-dom": "7.4.0",
"netlify-cli": "12.0.7",
"postcss": "8.4.17",
"postcss-preset-env": "7.8.2",
"rollup": "3.0.0",
"rollup-plugin-visualizer": "5.8.2",
"sass": "1.55.0",
"typescript": "4.8.4",
"vite": "3.1.7",
"vite-plugin-pwa": "0.13.1",
"vite-svg-loader": "3.6.0",
"vitest": "0.24.1",
"vue-tsc": "1.0.5",
"autoprefixer": "10.4.7",
"axios": "0.27.2",
"browserslist": "4.21.3",
"caniuse-lite": "1.0.30001373",
"cypress": "10.3.1",
"esbuild": "0.14.51",
"eslint": "8.20.0",
"eslint-plugin-vue": "9.3.0",
"express": "4.18.1",
"happy-dom": "6.0.4",
"netlify-cli": "10.13.0",
"postcss": "8.4.14",
"postcss-preset-env": "7.7.2",
"rollup": "2.77.0",
"rollup-plugin-visualizer": "5.7.1",
"sass": "1.54.0",
"typescript": "4.7.4",
"vite": "3.0.4",
"vite-plugin-pwa": "0.12.3",
"vite-svg-loader": "3.4.0",
"vitest": "0.20.2",
"vue-tsc": "0.38.9",
"wait-on": "6.0.1",
"workbox-cli": "6.5.4"
},
@ -110,5 +102,5 @@
}
},
"license": "AGPL-3.0-or-later",
"packageManager": "pnpm@7.13.4"
"packageManager": "yarn@1.22.19"
}

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
],
"packageRules": [
{
"matchPackageNames": ["netlify-cli", "happy-dom"],
"matchPackageNames": ["netlify-cli"],
"extends": ["schedule:weekly"]
},
{
@ -19,12 +19,6 @@
"matchPackagePrefixes": [
"@vueuse/"
]
},
{
"matchDepTypes": ["devDependencies"],
"automerge": true,
"automergeStrategy": "squash",
"automergeType": "pr"
}
]
}

View file

@ -15,9 +15,10 @@
</template>
<script lang="ts" setup>
import {computed, watch, type Ref} from 'vue'
import {computed, watch, Ref} from 'vue'
import {useRouter} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import isTouchDevice from 'is-touch-device'
import {success} from '@/message'
@ -33,20 +34,17 @@ import Ready from '@/components/misc/ready.vue'
import {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete'
import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
import {useAuthStore} from './stores/auth'
const baseStore = useBaseStore()
const authStore = useAuthStore()
const store = useStore()
const router = useRouter()
useBodyClass('is-touch', isTouchDevice())
const keyboardShortcutsActive = computed(() => baseStore.keyboardShortcutsActive)
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
const authUser = computed(() => authStore.authUser)
const authLinkShare = computed(() => authStore.authLinkShare)
const authUser = computed(() => store.getters['auth/authUser'])
const authLinkShare = computed(() => store.getters['auth/authLinkShare'])
const {t} = useI18n({useScope: 'global'})
@ -60,7 +58,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm)
success({message: t('user.deletion.confirmSuccess')})
authStore.refreshUserInfo()
store.dispatch('auth/refreshUserInfo')
}, { immediate: true })
// setup password reset redirect

View file

@ -7,12 +7,17 @@
:disabled="disabled || undefined"
ref="button"
>
<slot/>
<slot />
</component>
</template>
<script lang="ts">
export default { inheritAttrs: false }
import {defineComponent} from 'vue'
// see https://v3.vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script
export default defineComponent({
inheritAttrs: false,
})
</script>
<script lang="ts" setup>
@ -25,11 +30,11 @@ export default { inheritAttrs: false }
// NOTE: Do NOT use buttons with @click to push routes. => Use router-links instead!
import { ref, watchEffect, computed, useAttrs, type PropType } from 'vue'
import { ref, watchEffect, computed, useAttrs, PropType } from 'vue'
const BASE_BUTTON_TYPES_MAP = Object.freeze({
button: 'button',
submit: 'submit',
const BASE_BUTTON_TYPES_MAP = Object.freeze({
button: 'button',
submit: 'submit',
})
type BaseButtonTypes = keyof typeof BASE_BUTTON_TYPES_MAP
@ -47,7 +52,6 @@ const props = defineProps({
const componentNodeName = ref<Node['nodeName']>('button')
interface ElementBindings {
type?: string;
rel?: string;
@ -88,7 +92,6 @@ watchEffect(() => {
const isButton = computed(() => componentNodeName.value === 'button')
const button = ref()
function focus() {
button.value.focus()
}
@ -120,7 +123,7 @@ defineExpose({
user-select: none;
pointer-events: auto; // disable possible resets
&:focus, &.is-focused {
&:focus {
outline: transparent;
}

View file

@ -6,13 +6,13 @@
{{ $t('input.datemathHelp.intro') }}
</p>
<p>
<i18n-t keypath="input.datemathHelp.expression" scope="global">
<i18n-t keypath="input.datemathHelp.expression">
<code>now</code>
<code>||</code>
</i18n-t>
</p>
<p>
<i18n-t keypath="input.datemathHelp.similar" scope="global">
<i18n-t keypath="input.datemathHelp.similar">
<BaseButton
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
target="_blank">
@ -99,7 +99,7 @@
<tr>
<td><code>{{ exampleDate }}||+1M/d</code></td>
<td>
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth" scope="global">
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth">
<code>{{ exampleDate }}</code>
</i18n-t>
</td>
@ -110,10 +110,10 @@
</template>
<script lang="ts" setup>
import { formatDate } from '@/helpers/time/formatDate'
import {format} from 'date-fns'
import BaseButton from '@/components/base/BaseButton.vue'
const exampleDate = formatDate(new Date(), 'yyyy-MM-dd')
const exampleDate = format(new Date(), 'yyyy-MM-dd')
</script>
<style scoped>

View file

@ -71,6 +71,7 @@
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
@ -80,12 +81,11 @@ import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
import {useAuthStore} from '@/stores/auth'
const authStore = useAuthStore()
const store = useStore()
const {t} = useI18n({useScope: 'global'})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['dateChanged', 'update:modelValue'])
const props = defineProps({
modelValue: {
required: false,
@ -93,7 +93,7 @@ const props = defineProps({
})
// FIXME: This seems to always contain the default value - that breaks the picker
const weekStart = computed(() => authStore.settings.weekStart ?? 0)
const weekStart = computed<number>(() => store.state.auth.settings.weekStart ?? 0)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
@ -118,13 +118,7 @@ watch(
newValue => {
from.value = newValue.dateFrom
to.value = newValue.dateTo
// Only set the date back to flatpickr when it's an actual date.
// Otherwise flatpickr runs in an endless loop and slows down the browser.
const dateFrom = new Date(from.value)
const dateTo = new Date(to.value)
if (dateTo.getTime() === dateTo.getTime() && dateFrom.getTime() === dateFrom.getTime()) {
flatpickrRange.value = `${from.value} to ${to.value}`
}
flatpickrRange.value = `${from.value} to ${to.value}`
},
)
@ -133,6 +127,7 @@ function emitChanged() {
dateFrom: from.value === '' ? null : from.value,
dateTo: to.value === '' ? null : to.value,
}
emit('dateChanged', args)
emit('update:modelValue', args)
}

View file

@ -1,8 +1,8 @@
<template>
<BaseButton
class="menu-show-button"
@click="baseStore.toggleMenu()"
@shortkey="() => baseStore.toggleMenu()"
@click="$store.commit('toggleMenu')"
@shortkey="() => $store.commit('toggleMenu')"
v-shortcut="'Control+e'"
:title="$t('keyboardShortcuts.toggleMenu')"
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
@ -11,12 +11,12 @@
<script setup lang="ts">
import {computed} from 'vue'
import {useBaseStore} from '@/stores/base'
import {useStore} from 'vuex'
import BaseButton from '@/components/base/BaseButton.vue'
const baseStore = useBaseStore()
const menuActive = computed(() => baseStore.menuActive)
const store = useStore()
const menuActive = computed(() => store.state.menuActive)
</script>
<style lang="scss" scoped>

View file

@ -16,10 +16,6 @@
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
</h1>
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="info-button">
<icon icon="circle-info"/>
</BaseButton>
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
</template>
</div>
@ -70,7 +66,7 @@
{{ $t('navigation.privacy') }}
</dropdown-item>
<dropdown-item
@click="baseStore.setKeyboardShortcutsActive(true)"
@click="$store.commit('keyboardShortcutsActive', true)"
>
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
@ -92,8 +88,11 @@
<script setup lang="ts">
import {ref, computed, onMounted, nextTick} from 'vue'
import {useStore} from 'vuex'
import {useRouter} from 'vue-router'
import {RIGHTS as Rights} from '@/constants/rights'
import {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'
@ -104,25 +103,16 @@ import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
import {getListTitle} from '@/helpers/getListTitle'
const store = useStore()
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const userAvatar = computed(() => authStore.avatarUrl)
const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const userInfo = computed(() => store.state.auth.info)
const userAvatar = computed(() => store.state.auth.avatarUrl)
const currentList = computed(() => store.state.currentList)
const background = computed(() => store.state.background)
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
const canWriteCurrentList = computed(() => store.state.currentList.maxRight > Rights.READ)
const menuActive = computed(() => store.state.menuActive)
const usernameDropdown = ref()
const listTitle = ref()
@ -136,12 +126,15 @@ onMounted(async () => {
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
const router = useRouter()
function logout() {
authStore.logout()
store.dispatch('auth/logout')
router.push({name: 'user.login'})
}
function openQuickActions() {
baseStore.setQuickActionsActive(true)
store.commit(QUICK_ACTIONS_ACTIVE, true)
}
</script>
@ -289,21 +282,10 @@ $hamburger-menu-icon-width: 28px;
:deep(.dropdown-trigger) {
color: var(--grey-400);
margin-left: .5rem;
margin-left: 1rem;
height: 1rem;
width: 1rem;
cursor: pointer;
}
}
.info-button {
text-align: center;
height: 1.25rem;
line-height: 1.25rem;
width: 2rem;
margin-top: .25rem;
padding: 0 .5rem;
color: var(--grey-400);
margin-left: .5rem;
}
</style>

View file

@ -2,7 +2,7 @@
<div class="content-auth">
<BaseButton
v-if="menuActive"
@click="baseStore.setMenuActive(false)"
@click="$store.commit('menuActive', false)"
class="menu-hide-button d-print-none"
>
<icon icon="times"/>
@ -26,7 +26,7 @@
>
<BaseButton
v-if="menuActive"
@click="baseStore.setMenuActive(false)"
@click="$store.commit('menuActive', false)"
class="mobile-overlay d-print-none"
/>
@ -60,34 +60,81 @@
</template>
<script lang="ts" setup>
import {watch, computed} from 'vue'
import {useRoute} from 'vue-router'
import {watch, computed, shallowRef, watchEffect, VNode, h} from 'vue'
import {useStore} from 'vuex'
import {useRoute, useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
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 BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base'
import {useLabelStore} from '@/stores/labels'
function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
const routeWithModal = computed(() => {
return backdropView.value
? router.resolve(backdropView.value)
: route
})
const currentModal = shallowRef<VNode>()
watchEffect(() => {
if (!backdropView.value) {
currentModal.value = undefined
return
}
// logic from vue-router
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
const routePropsOption = route.matched[0]?.props.default
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: null
currentModal.value = h(
route.matched[0]?.components.default,
routeProps,
)
})
function closeModal() {
const historyState = computed(() => route.fullPath && window.history.state)
if (historyState.value) {
router.back()
} else {
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
router.push(backdropRoute)
}
}
return {routeWithModal, currentModal, closeModal}
}
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
const baseStore = useBaseStore()
const background = computed(() => baseStore.background)
const blurHash = computed(() => baseStore.blurHash)
const menuActive = computed(() => baseStore.menuActive)
const store = useStore()
const background = computed(() => store.state.background)
const blurHash = computed(() => store.state.blurHash)
const menuActive = computed(() => store.state.menuActive)
function showKeyboardShortcuts() {
baseStore.setKeyboardShortcutsActive(true)
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
}
const route = useRoute()
// hide menu on mobile
watch(() => route.fullPath, () => window.innerWidth < 769 && baseStore.setMenuActive(false))
watch(() => route.fullPath, () => window.innerWidth < 769 && store.commit(MENU_ACTIVE, false))
// FIXME: this is really error prone
// Reset the current list highlight in menu if the current route is not list related.
@ -109,16 +156,48 @@ watch(() => route.name as string, (routeName) => {
routeName.startsWith('user.settings')
)
) {
baseStore.handleSetCurrentList({list: null})
store.dispatch(CURRENT_LIST, {list: null})
}
})
// TODO: Reset the title if the page component does not set one itself
useRenewTokenOnFocus()
function useRenewTokenOnFocus() {
const router = useRouter()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
const userInfo = computed(() => store.state.auth.info)
const authenticated = computed(() => store.state.auth.authenticated)
// Try renewing the token every time vikunja is loaded initially
// (When opening the browser the focus event is not fired)
store.dispatch('auth/renewToken')
// Check if the token is still valid if the window gets focus again to maybe renew it
useEventListener('focus', () => {
if (!authenticated.value) {
return
}
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
if (expiresIn < 0) {
store.dispatch('auth/checkAuth')
router.push({name: 'user.login'})
return
}
// Check if the token is valid for less than 60 hours and renew if thats the case
if (expiresIn < 60 * 3600) {
store.dispatch('auth/renewToken')
console.debug('renewed token')
}
})
}
useRenewTokenOnFocus()
store.dispatch('labels/loadAllLabels')
</script>
<style lang="scss" scoped>

View file

@ -6,9 +6,8 @@
>
<div class="container has-text-centered link-share-view">
<div class="column is-10 is-offset-1">
<Logo class="logo" v-if="logoVisible"/>
<Logo class="logo"/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
@ -24,16 +23,14 @@
<script lang="ts" setup>
import {computed} from 'vue'
import {useBaseStore} from '@/stores/base'
import {useStore} from 'vuex'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
const store = useStore()
const currentList = computed(() => store.state.currentList)
const background = computed(() => store.state.background)
</script>
<style lang="scss" scoped>

View file

@ -56,10 +56,10 @@
class="menu-label"
v-tooltip="namespaceTitles[nk]"
>
<ColorBubble
<span
v-if="n.hexColor !== ''"
:color="n.hexColor"
class="mr-1"
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
/>
<span class="name">{{ namespaceTitles[nk] }}</span>
<div
@ -114,17 +114,17 @@
<span class="icon handle">
<icon icon="grip-lines"/>
</span>
<ColorBubble
v-if="l.hexColor !== ''"
:color="l.hexColor"
class="mr-1"
/>
<span
:style="{ backgroundColor: l.hexColor }"
class="color-bubble"
v-if="l.hexColor !== ''">
</span>
<span class="list-menu-title">{{ getListTitle(l) }}</span>
</BaseButton>
<BaseButton
class="favorite"
:class="{'is-favorite': l.isFavorite}"
@click="listStore.toggleListFavorite(l)"
@click="toggleFavoriteList(l)"
>
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
@ -141,8 +141,9 @@
<script setup lang="ts">
import {ref, computed, onMounted, onBeforeMount} from 'vue'
import {useStore} from 'vuex'
import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
import {SortableEvent} from 'sortablejs'
import BaseButton from '@/components/base/BaseButton.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
@ -150,17 +151,12 @@ import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings
import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue'
import {MENU_ACTIVE} from '@/store/mutation-types'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getListTitle} from '@/helpers/getListTitle'
import {useEventListener} from '@vueuse/core'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import NamespaceModel from '@/models/namespace'
import ListModel from '@/models/list'
const drag = ref(false)
const dragOptions = {
@ -168,15 +164,14 @@ const dragOptions = {
ghostClass: 'ghost',
}
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const currentList = computed(() => baseStore.currentList)
const menuActive = computed(() => baseStore.menuActive)
const loading = computed(() => namespaceStore.isLoading)
const store = useStore()
const currentList = computed(() => store.state.currentList)
const menuActive = computed(() => store.state.menuActive)
const loading = computed(() => store.state.loading && store.state.loadingModule === 'namespaces')
const namespaces = computed(() => {
return namespaceStore.namespaces.filter(n => !n.isArchived)
return (store.state.namespaces.namespaces as NamespaceModel[]).filter(n => !n.isArchived)
})
const activeLists = computed(() => {
return namespaces.value.map(({lists}) => {
@ -198,21 +193,29 @@ const namespaceListsCount = computed(() => {
useEventListener('resize', resize)
onMounted(() => resize())
const listStore = useListStore()
function toggleFavoriteList(list: ListModel) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
store.dispatch('lists/toggleListFavorite', list)
}
function resize() {
// Hide the menu by default on mobile
baseStore.setMenuActive(window.innerWidth >= 770)
store.commit(MENU_ACTIVE, window.innerWidth >= 770)
}
function toggleLists(namespaceId: INamespace['id']) {
function toggleLists(namespaceId: number) {
listsVisible.value[namespaceId] = !listsVisible.value[namespaceId]
}
const listsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
const listsVisible = ref<{ [id: NamespaceModel['id']]: boolean }>({})
// FIXME: async action will be unfinished when component mounts
onBeforeMount(async () => {
const namespaces = await namespaceStore.loadNamespaces()
const namespaces = await store.dispatch('namespaces/loadNamespaces') as NamespaceModel[]
namespaces.forEach(n => {
if (typeof listsVisible.value[n.id] === 'undefined') {
listsVisible.value[n.id] = true
@ -220,7 +223,7 @@ onBeforeMount(async () => {
})
})
function updateActiveLists(namespace: INamespace, activeLists: IList[]) {
function updateActiveLists(namespace: NamespaceModel, activeLists: ListModel[]) {
// 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.
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order
@ -231,29 +234,24 @@ function updateActiveLists(namespace: INamespace, activeLists: IList[]) {
...namespace.lists.filter(l => l.isArchived),
]
namespaceStore.setNamespaceById({
store.commit('namespaces/setNamespaceById', {
...namespace,
lists,
})
}
const listUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
const listUpdating = ref<{ [id: NamespaceModel['id']]: boolean }>({})
async function saveListPosition(e: SortableEvent) {
if (!e.newIndex && e.newIndex !== 0) return
if (!e.newIndex) return
const namespaceId = parseInt(e.to.dataset.namespaceId as string)
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string)
const listsActive = activeLists.value[newNamespaceIndex]
// If the list was dragged to the last position, Safari will report e.newIndex as the size of the listsActive
// array instead of using the position. Because the index is wrong in that case, dragging the list will fail.
// To work around that we're explicitly checking that case here and decrease the index.
const newIndex = e.newIndex === listsActive.length ? e.newIndex - 1 : e.newIndex
const list = listsActive[newIndex]
const listBefore = listsActive[newIndex - 1] ?? null
const listAfter = listsActive[newIndex + 1] ?? null
const list = listsActive[e.newIndex]
const listBefore = listsActive[e.newIndex - 1] ?? null
const listAfter = listsActive[e.newIndex + 1] ?? null
listUpdating.value[list.id] = true
const position = calculateItemPosition(
@ -262,8 +260,8 @@ async function saveListPosition(e: SortableEvent) {
)
try {
// create a copy of the list in order to not violate pinia manipulation
await listStore.updateList({
// create a copy of the list in order to not violate vuex mutations
await store.dispatch('lists/updateList', {
...list,
position,
namespaceId,

View file

@ -1,7 +1,7 @@
<template>
<div class="update-notification" v-if="updateAvailable">
<p>{{ $t('update.available') }}</p>
<x-button @click="refreshApp()" :shadow="false" class="has-no-text-wrap">
<x-button @click="refreshApp()" :shadow="false">
{{ $t('update.do') }}
</x-button>
</div>
@ -43,19 +43,24 @@ function refreshApp() {
<style lang="scss" scoped>
.update-notification {
margin: .5rem;
display: flex;
align-items: center;
background: $warning;
padding: .5rem;
padding: 0 .25rem;
border-radius: $radius;
font-size: .9rem;
color: var(--grey-900);
justify-content: space-between;
position: fixed;
bottom: 1rem;
width: 450px;
left: calc(50vw - 225px);
@media screen and (max-width: $desktop) {
position: fixed;
bottom: 1rem;
margin: 0;
width: 450px;
left: calc(50vw - 225px);
padding: .5rem;
}
@media screen and (max-width: $tablet) {
position: fixed;

View file

@ -9,27 +9,24 @@
}
]"
>
<icon
v-if="showIconOnly"
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : false}"
/>
<icon :icon="icon" v-if="showIconOnly"/>
<span class="icon is-small" v-else-if="icon !== ''">
<icon
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : false}"
/>
<icon :icon="icon"/>
</span>
<slot />
</BaseButton>
</template>
<script lang="ts">
export default { name: 'x-button' }
import {defineComponent} from 'vue'
export default defineComponent({
name: 'x-button',
})
</script>
<script setup lang="ts">
import {computed, useSlots, type PropType} from 'vue'
import {computed, useSlots, PropType} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
const BUTTON_TYPES_MAP = Object.freeze({
@ -49,10 +46,6 @@ const props = defineProps({
type: [String, Array],
default: '',
},
iconColor: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
@ -76,11 +69,9 @@ const showIconOnly = computed(() => props.icon !== '' && typeof slots.default ==
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
height: auto;
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: break-spaces;
&:hover {
box-shadow: var(--shadow-md);

View file

@ -34,8 +34,9 @@
</div>
</template>
<script setup lang="ts">
import {computed, ref, toRef, watch} from 'vue'
<script lang="ts">
import {defineComponent} from 'vue'
import {createRandomID} from '@/helpers/randomId'
const DEFAULT_COLORS = [
@ -47,57 +48,67 @@ const DEFAULT_COLORS = [
'#00db60',
]
const color = ref('')
const lastChangeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const defaultColors = ref(DEFAULT_COLORS)
const colorListID = ref(createRandomID())
const props = defineProps({
modelValue: {
type: String,
required: true,
export default defineComponent({
name: 'colorPicker',
data() {
return {
color: '',
lastChangeTimeout: null,
defaultColors: DEFAULT_COLORS,
colorListID: createRandomID(),
}
},
menuPosition: {
type: String,
default: 'top',
props: {
modelValue: {
type: String,
required: true,
},
menuPosition: {
type: String,
default: 'top',
},
},
emits: ['update:modelValue', 'change'],
watch: {
modelValue: {
handler(modelValue) {
this.color = modelValue
},
immediate: true,
},
color() {
this.update()
},
},
computed: {
isEmpty() {
return this.color === '#000000' || this.color === ''
},
},
methods: {
update(force = false) {
if(this.isEmpty && !force) {
return
}
if (this.lastChangeTimeout !== null) {
clearTimeout(this.lastChangeTimeout)
}
this.lastChangeTimeout = setTimeout(() => {
this.$emit('update:modelValue', this.color)
this.$emit('change')
}, 500)
},
reset() {
// FIXME: I havn't found a way to make it clear to the user the color war reset.
// Not sure if verte is capable of this - it does not show the change when setting this.color = ''
this.color = ''
this.update(true)
},
},
})
const emit = defineEmits(['update:modelValue'])
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
(newValue) => {
color.value = newValue
},
{immediate: true},
)
watch(color, () => update())
const isEmpty = computed(() => color.value === '#000000' || color.value === '')
function update(force = false) {
if(isEmpty.value && !force) {
return
}
if (lastChangeTimeout.value !== null) {
clearTimeout(lastChangeTimeout.value)
}
lastChangeTimeout.value = setTimeout(() => {
emit('update:modelValue', color.value)
}, 500)
}
function reset() {
// FIXME: I havn't found a way to make it clear to the user the color war reset.
// Not sure if verte is capable of this - it does not show the change when setting this.color = ''
color.value = ''
update(true)
}
</script>
<style lang="scss" scoped>

View file

@ -88,152 +88,155 @@
</div>
</template>
<script setup lang="ts">
import {ref, onMounted, onBeforeUnmount, toRef, watch, computed, type PropType} from 'vue'
<script lang="ts">
import {defineComponent} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {i18n} from '@/i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import {formatDate, formatDateShort} from '@/helpers/time/formatDate'
import {format} from 'date-fns'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useAuthStore} from '@/stores/auth'
import {useI18n} from 'vue-i18n'
const props = defineProps({
modelValue: {
type: [Date, null, String] as PropType<Date | null | string>,
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
default: null,
export default defineComponent({
name: 'datepicker',
data() {
return {
date: null,
show: false,
changed: false,
}
},
chooseDateLabel: {
type: String,
default() {
const {t} = useI18n({useScope: 'global'})
return t('input.datepicker.chooseDate')
components: {
flatPickr,
BaseButton,
},
props: {
modelValue: {
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
},
chooseDateLabel: {
type: String,
default() {
return i18n.global.t('input.datepicker.chooseDate')
},
},
disabled: {
type: Boolean,
default: false,
},
},
disabled: {
type: Boolean,
default: false,
emits: ['update:modelValue', 'change', 'close', 'close-on-change'],
mounted() {
document.addEventListener('click', this.hideDatePopup)
},
beforeUnmount() {
document.removeEventListener('click', this.hideDatePopup)
},
watch: {
modelValue: {
handler: 'setDateValue',
immediate: true,
},
},
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,
},
}
},
// 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: {
set(newValue) {
this.date = createDateFromString(newValue)
this.updateData()
},
get() {
if (!this.date) {
return ''
}
return format(this.date, 'yyy-LL-dd H:mm')
},
},
},
methods: {
setDateValue(newVal) {
if (newVal === null) {
this.date = null
return
}
this.date = createDateFromString(newVal)
},
updateData() {
this.changed = true
this.$emit('update:modelValue', this.date)
this.$emit('change', this.date)
},
toggleDatePopup() {
if (this.disabled) {
return
}
this.show = !this.show
},
hideDatePopup(e) {
if (this.show) {
closeWhenClickedOutside(e, this.$refs.datepickerPopup, this.close)
}
},
close() {
// Kind of dirty, but the timeout allows us to enter a time and click on "confirm" without
// having to click on another input field before it is actually used.
setTimeout(() => {
this.show = false
this.$emit('close', this.changed)
if (this.changed) {
this.changed = false
this.$emit('close-on-change', this.changed)
}
}, 200)
},
setDate(date) {
if (this.date === null) {
this.date = new Date()
}
const interval = calculateDayInterval(date)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
this.date = newDate
this.flatPickrDate = newDate
this.updateData()
},
getDayIntervalFromString(date) {
return calculateDayInterval(date)
},
getWeekdayFromStringInterval(date) {
const interval = calculateDayInterval(date)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return format(newDate, 'E')
},
},
})
const emit = defineEmits(['update:modelValue', 'close', 'close-on-change'])
const {t} = useI18n({useScope: 'global'})
const date = ref<Date | null>()
const show = ref(false)
const changed = ref(false)
onMounted(() => document.addEventListener('click', hideDatePopup))
onBeforeUnmount(() =>document.removeEventListener('click', hideDatePopup))
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
setDateValue,
{immediate: true},
)
const authStore = useAuthStore()
const weekStart = computed(() => authStore.settings.weekStart)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: weekStart.value,
},
}))
// 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.
const flatPickrDate = computed({
set(newValue: string | Date) {
date.value = createDateFromString(newValue)
updateData()
},
get() {
if (!date.value) {
return ''
}
return formatDate(date.value, 'yyy-LL-dd H:mm')
},
})
function setDateValue(dateString: string | Date | null) {
if (dateString === null) {
date.value = null
return
}
date.value = createDateFromString(dateString)
}
function updateData() {
changed.value = true
emit('update:modelValue', date.value)
}
function toggleDatePopup() {
if (props.disabled) {
return
}
show.value = !show.value
}
const datepickerPopup = ref<HTMLElement | null>(null)
function hideDatePopup(e) {
if (show.value) {
closeWhenClickedOutside(e, datepickerPopup.value, close)
}
}
function close() {
// Kind of dirty, but the timeout allows us to enter a time and click on "confirm" without
// having to click on another input field before it is actually used.
setTimeout(() => {
show.value = false
emit('close', changed.value)
if (changed.value) {
changed.value = false
emit('close-on-change', changed.value)
}
}, 200)
}
function setDate(dateString: string) {
if (date.value === null) {
date.value = new Date()
}
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
date.value = newDate
flatPickrDate.value = newDate
updateData()
}
function getWeekdayFromStringInterval(dateString: string) {
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return formatDate(newDate, 'E')
}
</script>
<style lang="scss" scoped>

View file

@ -4,7 +4,7 @@
<vue-easymde
:configs="config"
@change="() => bubble()"
@change="bubble"
@update:modelValue="handleInput"
class="content"
v-if="isEditActive"
@ -16,29 +16,14 @@
<p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText">
{{ emptyText }}
<template v-if="isEditEnabled">
<ButtonLink
@click="toggleEdit"
v-shortcut="editShortcut"
class="d-print-none">
{{ $t('input.editor.edit') }}
</ButtonLink>.
<ButtonLink @click="toggleEdit" class="d-print-none">{{ $t('input.editor.edit') }}</ButtonLink>.
</template>
</p>
<ul class="actions d-print-none" v-if="bottomActions.length > 0">
<li v-if="isEditEnabled && !showPreviewText && showSave">
<BaseButton
v-if="showEditButton"
@click="toggleEdit"
v-shortcut="editShortcut">
{{ $t('input.editor.edit') }}
</BaseButton>
<BaseButton
v-else-if="isEditActive"
@click="toggleEdit"
class="done-edit">
{{ $t('misc.save') }}
</BaseButton>
<BaseButton v-if="showEditButton" @click="toggleEdit">{{ $t('input.editor.edit') }}</BaseButton>
<BaseButton v-else-if="isEditActive" @click="toggleEdit" class="done-edit">{{ $t('misc.save') }}</BaseButton>
</li>
<li v-for="(action, k) in bottomActions" :key="k">
<BaseButton @click="action.action">{{ action.title }}</BaseButton>
@ -47,11 +32,7 @@
<template v-else-if="isEditEnabled && showSave">
<ul v-if="showEditButton" class="actions d-print-none">
<li>
<BaseButton
@click="toggleEdit"
v-shortcut="editShortcut">
{{ $t('input.editor.edit') }}
</BaseButton>
<BaseButton @click="toggleEdit">{{ $t('input.editor.edit') }}</BaseButton>
</li>
</ul>
<x-button
@ -66,245 +47,276 @@
</div>
</template>
<script setup lang="ts">
import {computed, nextTick, onMounted, ref, toRefs, watch} from 'vue'
<script lang="ts">
import {defineComponent} from 'vue'
import VueEasymde from './vue-easymde.vue'
import {marked} from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/common'
import {createEasyMDEConfig} from './editorConfig'
import AttachmentModel from '@/models/attachment'
import AttachmentService from '@/services/attachment'
import {setupMarkdownRenderer} from '@/helpers/markdownRenderer'
import {findCheckboxesInText} from '@/helpers/checklistFromText'
import AttachmentModel from '../../models/attachment'
import AttachmentService from '../../services/attachment'
import {findCheckboxesInText} from '../../helpers/checklistFromText'
import {createRandomID} from '@/helpers/randomId'
import BaseButton from '@/components/base/BaseButton.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
import type { IAttachment } from '@/modelTypes/IAttachment'
import type { ITask } from '@/modelTypes/ITask'
const props = defineProps({
modelValue: {
type: String,
default: '',
export default defineComponent({
name: 'editor',
components: {
VueEasymde,
BaseButton,
ButtonLink,
},
placeholder: {
type: String,
default: '',
props: {
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
uploadEnabled: {
type: Boolean,
default: false,
},
uploadCallback: {
type: Function,
},
hasPreview: {
type: Boolean,
default: true,
},
previewIsDefault: {
type: Boolean,
default: true,
},
isEditEnabled: {
default: true,
},
bottomActions: {
default: () => [],
},
emptyText: {
type: String,
default: '',
},
showSave: {
type: Boolean,
default: false,
},
},
uploadEnabled: {
type: Boolean,
default: false,
emits: ['update:modelValue', 'change'],
computed: {
showPreviewText() {
return this.isPreviewActive && this.text === '' && this.emptyText !== ''
},
showEditButton() {
return !this.isEditActive && this.text !== ''
},
},
uploadCallback: {
type: Function,
},
hasPreview: {
type: Boolean,
default: true,
},
previewIsDefault: {
type: Boolean,
default: true,
},
isEditEnabled: {
default: true,
},
bottomActions: {
default: () => [],
},
emptyText: {
type: String,
default: '',
},
showSave: {
type: Boolean,
default: false,
},
// If a key is passed the editor will go in "edit" mode when the key is pressed.
// Disabled if an empty string is passed.
editShortcut: {
type: String,
default: '',
},
})
data() {
return {
text: '',
changeTimeout: null,
isEditActive: false,
isPreviewActive: true,
const emit = defineEmits(['update:modelValue'])
const text = ref('')
const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const isEditActive = ref(false)
const isPreviewActive = ref(true)
const showPreviewText = computed(() => isPreviewActive.value && text.value === '' && props.emptyText !== '')
const showEditButton = computed(() => !isEditActive.value && text.value !== '')
const preview = ref('')
const attachmentService = new AttachmentService()
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
const loadedAttachments = ref<{[key: CacheKey]: string}>({})
const config = ref(createEasyMDEConfig({
placeholder: props.placeholder,
uploadImage: props.uploadEnabled,
imageUploadFunction: props.uploadCallback,
}))
const checkboxId = ref(createRandomID())
const {modelValue} = toRefs(props)
watch(
modelValue,
async (value) => {
text.value = value
await nextTick()
renderPreview()
preview: '',
attachmentService: null,
loadedAttachments: {},
config: createEasyMDEConfig({
placeholder: this.placeholder,
uploadImage: this.uploadEnabled,
imageUploadFunction: this.uploadCallback,
}),
checkboxId: createRandomID(),
}
},
)
watch: {
modelValue(modelValue) {
this.text = modelValue
this.$nextTick(this.renderPreview)
},
text(newVal, oldVal) {
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
if (oldVal === '' && this.text === this.modelValue) {
return
}
this.bubble()
},
},
mounted() {
if (this.modelValue !== '') {
this.text = this.modelValue
}
watch(
text,
(newVal, oldVal) => {
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
if (oldVal === '' && text.value === modelValue.value) {
if (this.previewIsDefault && this.hasPreview) {
this.$nextTick(this.renderPreview)
return
}
bubble()
this.isPreviewActive = false
this.isEditActive = true
},
)
methods: {
// This gets triggered when only pasting content into the editor.
// A change event would not get generated by that, an input event does.
// Therefore, we're using this handler to catch paste events.
// But because this also gets triggered when typing into the editor, we give
// it a higher timeout to make the timouts cancel each other in that case so
// 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) {
return
}
this.text = val
this.bubble(1000)
},
bubble(timeout = 500) {
if (this.changeTimeout !== null) {
clearTimeout(this.changeTimeout)
}
onMounted(() => {
if (modelValue.value !== '') {
text.value = modelValue.value
}
this.changeTimeout = setTimeout(() => {
this.$emit('update:modelValue', this.text)
this.$emit('change', this.text)
}, timeout)
},
replaceAt(str, index, replacement) {
return str.substr(0, index) + replacement + str.substr(index + replacement.length)
},
findNthIndex(str, n) {
const checkboxes = findCheckboxesInText(str)
return checkboxes[n]
},
renderPreview() {
const renderer = new marked.Renderer()
const linkRenderer = renderer.link
if (props.previewIsDefault && props.hasPreview) {
nextTick(() => renderPreview())
return
}
let checkboxNum = -1
marked.use({
renderer: {
image: (src, title, text) => {
isPreviewActive.value = false
isEditActive.value = true
})
title = title ? ` title="${title}` : ''
// If the url starts with the api url, the image is likely an attachment and
// we'll need to download and parse it properly.
if (src.substr(0, window.API_URL.length + 7) === `${window.API_URL}/tasks/`) {
return `<img data-src="${src}" alt="${text}" ${title} class="attachment-image"/>`
}
// This gets triggered when only pasting content into the editor.
// A change event would not get generated by that, an input event does.
// Therefore, we're using this handler to catch paste events.
// But because this also gets triggered when typing into the editor, we give
// it a higher timeout to make the timouts cancel each other in that case so
// that in the end, only one change event is triggered to the outside per change.
function handleInput(val: string) {
// Don't bubble if the text is up to date
if (val === text.value) {
return
}
return `<img src="${src}" alt="${text}" ${title}/>`
},
checkbox: (checked) => {
if (checked) {
checked = ' checked="checked"'
}
text.value = val
bubble(1000)
}
checkboxNum++
return `<input type="checkbox" data-checkbox-num="${checkboxNum}" ${checked} class="text-checkbox-${this.checkboxId}"/>`
},
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" ')
},
},
highlight: function (code, language) {
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext'
return hljs.highlight(code, {language: validLanguage}).value
},
})
function bubble(timeout = 500) {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
this.preview = DOMPurify.sanitize(marked(this.text), {ADD_ATTR: ['target']})
changeTimeout.value = setTimeout(() => {
emit('update:modelValue', text.value)
}, timeout)
}
// 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.
// To work around this, we modify the url after rendering it in the vue component.
// We're doing the whole thing in the next tick to ensure the image elements are available in the
// dom tree. If we're calling this right after setting this.preview it could be the images were
// not already made available.
// Some docs at https://stackoverflow.com/q/62865160/10924593
this.$nextTick(async () => {
const attachmentImage = document.getElementsByClassName('attachment-image')
if (attachmentImage) {
for (const img of attachmentImage) {
// The url is something like /tasks/<id>/attachments/<id>
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}`
function replaceAt(str: string, index: number, replacement: string) {
return str.slice(0, index) + replacement + str.slice(index + replacement.length)
}
if (typeof this.loadedAttachments[cacheKey] !== 'undefined') {
img.src = this.loadedAttachments[cacheKey]
continue
}
function findNthIndex(str: string, n: number) {
const checkboxes = findCheckboxesInText(str)
return checkboxes[n]
}
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
function renderPreview() {
setupMarkdownRenderer(checkboxId.value)
if (this.attachmentService === null) {
this.attachmentService = new AttachmentService()
}
preview.value = DOMPurify.sanitize(marked(text.value), {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.
// To work around this, we modify the url after rendering it in the vue component.
// We're doing the whole thing in the next tick to ensure the image elements are available in the
// dom tree. If we're calling this right after setting this.preview it could be the images were
// not already made available.
// Some docs at https://stackoverflow.com/q/62865160/10924593
nextTick().then(async () => {
const attachmentImage = document.querySelectorAll<HTMLImageElement>('.attachment-image')
if (attachmentImage) {
Array.from(attachmentImage).forEach(async (img) => {
// The url is something like /tasks/<id>/attachments/<id>
const parts = img.dataset.src?.slice(window.API_URL.length + 1).split('/')
const taskId = Number(parts[1])
const attachmentId = Number(parts[3])
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
if (typeof loadedAttachments.value[cacheKey] !== 'undefined') {
img.src = loadedAttachments.value[cacheKey]
return
const url = await this.attachmentService.getBlobUrl(attachment)
img.src = url
this.loadedAttachments[cacheKey] = url
}
}
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
const url = await attachmentService.getBlobUrl(attachment)
img.src = url
loadedAttachments.value[cacheKey] = url
const textCheckbox = document.getElementsByClassName(`text-checkbox-${this.checkboxId}`)
if (textCheckbox) {
for (const check of textCheckbox) {
check.removeEventListener('change', this.handleCheckboxClick)
check.addEventListener('change', this.handleCheckboxClick)
check.parentElement.classList.add('has-checkbox')
}
}
})
}
},
handleCheckboxClick(e) {
// Find the original markdown checkbox this is targeting
const checked = e.target.checked
const numMarkdownCheck = parseInt(e.target.dataset.checkboxNum)
const textCheckbox = document.querySelectorAll<HTMLInputElement>(`.text-checkbox-${checkboxId.value}`)
if (textCheckbox) {
Array.from(textCheckbox).forEach(check => {
check.removeEventListener('change', handleCheckboxClick)
check.addEventListener('change', handleCheckboxClick)
check.parentElement?.classList.add('has-checkbox')
})
}
})
}
const index = this.findNthIndex(this.text, numMarkdownCheck)
if (index < 0 || typeof index === 'undefined') {
console.debug('no index found')
return
}
console.debug(index, this.text.substr(index, 9))
function handleCheckboxClick(e: Event) {
// Find the original markdown checkbox this is targeting
const checked = (e.target as HTMLInputElement).checked
const numMarkdownCheck = Number((e.target as HTMLInputElement).dataset.checkboxNum)
const listPrefix = this.text.substr(index, 1)
const index = findNthIndex(text.value, numMarkdownCheck)
if (index < 0 || typeof index === 'undefined') {
console.debug('no index found')
return
}
console.debug(index, text.value.slice(index, 9))
const listPrefix = text.value.slice(index, 1)
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
bubble()
renderPreview()
}
function toggleEdit() {
if (isEditActive.value) {
isPreviewActive.value = true
isEditActive.value = false
renderPreview()
bubble(0) // save instantly
} else {
isPreviewActive.value = false
isEditActive.value = true
}
}
if (checked) {
this.text = this.replaceAt(this.text, index, `${listPrefix} [x] `)
} else {
this.text = this.replaceAt(this.text, index, `${listPrefix} [ ] `)
}
this.bubble()
this.renderPreview()
},
toggleEdit() {
if (this.isEditActive) {
this.isPreviewActive = true
this.isEditActive = false
this.renderPreview()
this.bubble(0) // save instantly
} else {
this.isPreviewActive = false
this.isEditActive = true
}
},
},
})
</script>
<style lang="scss">

View file

@ -4,9 +4,8 @@
:checked="checked"
:disabled="disabled || undefined"
:id="checkBoxId"
@change="(event: Event) => updateData((event.target as HTMLInputElement).checked)"
type="checkbox"
/>
@change="(event) => updateData(event.target.checked)"
type="checkbox"/>
<label :for="checkBoxId" class="check">
<svg height="18px" viewBox="0 0 18 18" width="18px">
<path
@ -20,42 +19,47 @@
</div>
</template>
<script setup lang="ts">
import {ref, toRef, watch} from 'vue'
<script lang="ts">
import {defineComponent} from 'vue'
import {createRandomID} from '@/helpers/randomId'
const checked = ref(false)
const checkBoxId = `fancycheckbox_${createRandomID()}`
const props = defineProps({
modelValue: {
type: Boolean,
required: false,
export default defineComponent({
name: 'fancycheckbox',
data() {
return {
checked: false,
checkBoxId: `fancycheckbox_${createRandomID()}`,
}
},
disabled: {
type: Boolean,
required: false,
default: false,
props: {
modelValue: {
required: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
emits: ['update:modelValue', 'change'],
watch: {
modelValue: {
handler(modelValue) {
this.checked = modelValue
},
immediate: true,
},
},
methods: {
updateData(checked) {
this.checked = checked
this.$emit('update:modelValue', checked)
this.$emit('change', checked)
},
},
})
const emit = defineEmits(['update:modelValue', 'change'])
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
newValue => {
checked.value = newValue
},
{immediate: true},
)
function updateData(newChecked: boolean) {
checked.value = newChecked
emit('update:modelValue', newChecked)
emit('change', newChecked)
}
</script>

View file

@ -39,11 +39,11 @@
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<BaseButton
class="is-fullwidth"
v-for="(data, index) in filteredSearchResults"
:key="index"
:ref="(el) => setResult(el, index)"
@keydown.up.prevent="() => preSelect(index - 1)"
@keydown.down.prevent="() => preSelect(index + 1)"
v-for="(data, key) in filteredSearchResults"
:key="key"
:ref="`result-${key}`"
@keydown.up.prevent="() => preSelect(key - 1)"
@keydown.down.prevent="() => preSelect(key + 1)"
@click.prevent.stop="() => select(data)"
>
<span>
@ -59,7 +59,7 @@
<BaseButton
v-if="creatableAvailable"
class="is-fullwidth"
:ref="(el) => setResult(el, filteredSearchResults.length)"
:ref="`result-${filteredSearchResults.length}`"
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
@keyup.enter.prevent="create"
@ -82,10 +82,9 @@
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance, type PropType} from 'vue'
import {useI18n} from 'vue-i18n'
<script lang="ts">
import {defineComponent} from 'vue'
import {i18n} from '@/i18n'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import BaseButton from '@/components/base/BaseButton.vue'
@ -99,298 +98,294 @@ function elementInResults(elem: string | any, label: string, query: string): boo
return elem === query
}
const props = defineProps({
// When true, shows a loading spinner
loading: {
type: Boolean,
default: false,
export default defineComponent({
name: 'multiselect',
components: {
BaseButton,
},
// The placeholder of the search input
placeholder: {
type: String,
default: '',
data() {
return {
query: '',
searchTimeout: null,
localLoading: false,
showSearchResults: false,
internalValue: null,
}
},
// The search results where the @search listener needs to put the results into
searchResults: {
type: Array as PropType<{[id: string]: any}>,
default: () => [],
},
// 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.
label: {
type: String,
default: '',
},
// The object with the value, updated every time an entry is selected.
modelValue: {
default: null,
},
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
creatable: {
type: Boolean,
default: false,
},
// The text shown next to the new value option.
createPlaceholder: {
type: String,
default() {
const {t} = useI18n({useScope: 'global'})
return t('input.multiselect.createPlaceholder')
props: {
// When true, shows a loading spinner
loading: {
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.
label: {
type: String,
default() {
return ''
},
},
// The object with the value, updated every time an entry is selected.
modelValue: {
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: {
type: Boolean,
default: false,
},
// The text shown next to the new value option.
createPlaceholder: {
type: String,
default() {
return i18n.global.t('input.multiselect.createPlaceholder')
},
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
return i18n.global.t('input.multiselect.selectPlaceholder')
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
multiple: {
type: Boolean,
default: false,
},
// If true, displays the search results inline instead of using a dropdown.
inline: {
type: Boolean,
default: false,
},
// If true, shows search results when no query is specified.
showEmpty: {
type: Boolean,
default: 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
},
},
closeAfterSelect: {
type: Boolean,
default: true,
},
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
const {t} = useI18n({useScope: 'global'})
return t('input.multiselect.selectPlaceholder')
/**
* Available events:
* @search: Triggered every time the search query input changes
* @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
* @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
* @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
*/
emits: ['update:modelValue', 'search', 'select', 'create', 'remove'],
mounted() {
document.addEventListener('click', this.hideSearchResultsHandler)
},
beforeUnmount() {
document.removeEventListener('click', this.hideSearchResultsHandler)
},
watch: {
modelValue: {
handler(value) {
this.setSelectedObject(value)
},
immediate: true,
deep: true,
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
multiple: {
type: Boolean,
default: false,
computed: {
searchResultsVisible() {
if (this.query === '' && !this.showEmpty) {
return false
}
return this.showSearchResults && (
(this.filteredSearchResults.length > 0) ||
(this.creatable && this.query !== '')
)
},
creatableAvailable() {
const hasResult = this.filteredSearchResults.some(elem => elementInResults(elem, this.label, this.query))
const hasQueryAlreadyAdded = Array.isArray(this.internalValue) && this.internalValue.some(elem => elementInResults(elem, this.label, this.query))
return this.creatable
&& this.query !== ''
&& !(hasResult || hasQueryAlreadyAdded)
},
filteredSearchResults() {
if (this.multiple && this.internalValue !== null && Array.isArray(this.internalValue)) {
return this.searchResults.filter(item => !this.internalValue.some(e => e === item))
}
return this.searchResults
},
hasMultiple() {
return this.multiple && Array.isArray(this.internalValue) && this.internalValue.length > 0
},
},
// If true, displays the search results inline instead of using a dropdown.
inline: {
type: Boolean,
default: false,
},
// If true, shows search results when no query is specified.
showEmpty: {
type: Boolean,
default: 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: 200,
},
closeAfterSelect: {
type: Boolean,
default: true,
methods: {
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
search() {
// 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
}
this.localLoading = true
this.searchTimeout = setTimeout(() => {
this.$emit('search', this.query)
setTimeout(() => {
this.localLoading = false
}, 100) // The duration of the loading timeout of the services
this.showSearchResults = true
}, this.searchDelay)
},
hideSearchResultsHandler(e) {
closeWhenClickedOutside(e, this.$refs.multiselectRoot, this.closeSearchResults)
},
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) {
this.internalValue = []
}
this.internalValue.push(object)
} else {
this.internalValue = object
}
this.$emit('update:modelValue', this.internalValue)
this.$emit('select', object)
this.setSelectedObject(object)
if (this.closeAfterSelect && this.filteredSearchResults.length > 0 && !this.creatableAvailable) {
this.closeSearchResults()
}
},
setSelectedObject(object, resetOnly = false) {
this.internalValue = object
// We assume we're getting an array when multiple is enabled and can therefore leave the query
// value etc as it is
if (this.multiple) {
this.query = ''
return
}
if (object === null) {
this.query = ''
return
}
if (resetOnly) {
return
}
this.query = this.label !== '' ? object[this.label] : object
},
preSelect(index) {
if (index < 0) {
this.$refs.searchInput.focus()
return
}
const elems = this.$refs[`result-${index}`]
if (typeof elems === 'undefined' || elems.length === 0) {
return
}
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems.focus()
},
create() {
if (this.query === '') {
return
}
this.$emit('create', this.query)
this.setSelectedObject(this.query, true)
this.closeSearchResults()
},
createOrSelectOnEnter() {
if (!this.creatableAvailable && this.searchResults.length === 1) {
this.select(this.searchResults[0])
return
}
if (!this.creatableAvailable) {
// Check if there's an exact match for our search term
const exactMatch = this.filteredSearchResults.find(elem => elementInResults(elem, this.label, this.query))
if(exactMatch) {
this.select(exactMatch)
}
return
}
this.create()
},
remove(item) {
for (const ind in this.internalValue) {
if (this.internalValue[ind] === item) {
this.internalValue.splice(ind, 1)
break
}
}
this.$emit('update:modelValue', this.internalValue)
this.$emit('remove', item)
},
focus() {
this.$refs.searchInput.focus()
},
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: null): void
// @search: Triggered every time the search query input changes
(e: 'search', query: string): void
// @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
(e: 'select', value: null): void
// @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
(e: 'create', query: string): void
// @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
(e: 'remove', value: null): void
}>()
const query = ref('')
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const localLoading = ref(false)
const showSearchResults = ref(false)
const internalValue = ref<string | {[key: string]: any} | any[] | null>(null)
onMounted(() => document.addEventListener('click', hideSearchResultsHandler))
onBeforeUnmount(() => document.removeEventListener('click', hideSearchResultsHandler))
const {modelValue, searchResults} = toRefs(props)
watch(
modelValue,
(value) => setSelectedObject(value),
{
immediate: true,
deep: true,
},
)
const searchResultsVisible = computed(() => {
if (query.value === '' && !props.showEmpty) {
return false
}
return showSearchResults.value && (
(filteredSearchResults.value.length > 0) ||
(props.creatable && query.value !== '')
)
})
const creatableAvailable = computed(() => {
const hasResult = filteredSearchResults.value.some((elem: any) => elementInResults(elem, props.label, query.value))
const hasQueryAlreadyAdded = Array.isArray(internalValue.value) && internalValue.value.some(elem => elementInResults(elem, props.label, query.value))
return props.creatable
&& query.value !== ''
&& !(hasResult || hasQueryAlreadyAdded)
})
const filteredSearchResults = computed(() => {
const currentInternal = internalValue.value
if (props.multiple && currentInternal !== null && Array.isArray(currentInternal)) {
return searchResults.value.filter((item: any) => !currentInternal.some(e => e === item))
}
return searchResults.value
})
const hasMultiple = computed(() => {
return props.multiple && Array.isArray(internalValue.value) && internalValue.value.length > 0
})
const searchInput = ref<HTMLInputElement | null>(null)
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
function search() {
// Updating the query with a binding does not work on mobile for some reason,
// getting the value manual does.
query.value = searchInput.value?.value || ''
if (searchTimeout.value !== null) {
clearTimeout(searchTimeout.value)
searchTimeout.value = null
}
localLoading.value = true
searchTimeout.value = setTimeout(() => {
emit('search', query.value)
setTimeout(() => {
localLoading.value = false
}, 100) // The duration of the loading timeout of the services
showSearchResults.value = true
}, props.searchDelay)
}
const multiselectRoot = ref<HTMLElement | null>(null)
function hideSearchResultsHandler(e: MouseEvent) {
closeWhenClickedOutside(e, multiselectRoot.value, closeSearchResults)
}
function closeSearchResults() {
showSearchResults.value = false
}
function 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(() => {
showSearchResults.value = true
}, 10)
}
function select(object: {[key: string]: any}) {
if (props.multiple) {
if (internalValue.value === null) {
internalValue.value = []
}
(internalValue.value as any[]).push(object)
} else {
internalValue.value = object
}
emit('update:modelValue', internalValue.value)
emit('select', object)
setSelectedObject(object)
if (props.closeAfterSelect && filteredSearchResults.value.length > 0 && !creatableAvailable.value) {
closeSearchResults()
}
}
function setSelectedObject(object: string | {[id: string]: any} | null, resetOnly = false) {
internalValue.value = object
// We assume we're getting an array when multiple is enabled and can therefore leave the query
// value etc as it is
if (props.multiple) {
query.value = ''
return
}
if (object === null) {
query.value = ''
return
}
if (resetOnly) {
return
}
query.value = props.label !== '' ? object[props.label] : object
}
const results = ref<(Element | ComponentPublicInstance)[]>([])
function setResult(el: Element | ComponentPublicInstance | null, index: number) {
if (el === null) {
delete results.value[index]
} else {
results.value[index] = el
}
}
function preSelect(index: number) {
if (index < 0) {
searchInput.value?.focus()
return
}
const elems = results.value[index]
if (typeof elems === 'undefined' || elems.length === 0) {
return
}
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems.focus()
}
function create() {
if (query.value === '') {
return
}
emit('create', query.value)
setSelectedObject(query.value, true)
closeSearchResults()
}
function createOrSelectOnEnter() {
if (!creatableAvailable.value && searchResults.value.length === 1) {
select(searchResults.value[0])
return
}
if (!creatableAvailable.value) {
// Check if there's an exact match for our search term
const exactMatch = filteredSearchResults.value.find((elem: any) => elementInResults(elem, props.label, query.value))
if(exactMatch) {
select(exactMatch)
}
return
}
create()
}
function remove(item: any) {
for (const ind in internalValue.value) {
if (internalValue.value[ind] === item) {
internalValue.value.splice(ind, 1)
break
}
}
emit('update:modelValue', internalValue.value)
emit('remove', item)
}
function focus() {
searchInput.value?.focus()
}
</script>
<style lang="scss" scoped>

View file

@ -1,6 +1,6 @@
<template>
<dropdown>
<template v-if="isSavedFilter(list)">
<template v-if="isSavedFilter">
<dropdown-item
:to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
icon="pen"
@ -55,13 +55,13 @@
>
{{ $t('menu.archive') }}
</dropdown-item>
<Subscription
<task-subscription
class="has-no-shadow"
:is-button="false"
entity="list"
:entity-id="list.id"
:model-value="list.subscription"
@update:model-value="setSubscriptionInStore"
:subscription="list.subscription"
@change="sub => subscription = sub"
type="dropdown"
/>
<dropdown-item
@ -76,42 +76,29 @@
</template>
<script setup lang="ts">
import {ref, computed, watchEffect, type PropType} from 'vue'
import {ref, computed, watchEffect} from 'vue'
import {useStore} from 'vuex'
import {isSavedFilter} from '@/helpers/savedFilter'
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Subscription from '@/components/misc/subscription.vue'
import type {IList} from '@/modelTypes/IList'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {useConfigStore} from '@/stores/config'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import TaskSubscription from '@/components/misc/subscription.vue'
import ListModel from '@/models/list'
import SubscriptionModel from '@/models/subscription'
const props = defineProps({
list: {
type: Object as PropType<IList>,
type: ListModel,
required: true,
},
})
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
const subscription = ref<SubscriptionModel | null>(null)
watchEffect(() => {
subscription.value = props.list.subscription ?? null
})
const configStore = useConfigStore()
const backgroundsEnabled = computed(() => configStore.enabledBackgroundProviders?.length > 0)
function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub
const updatedList = {
...props.list,
subscription: sub,
}
listStore.setList(updatedList)
namespaceStore.setListInNamespaceById(updatedList)
}
const store = useStore()
const backgroundsEnabled = computed(() => store.state.config.enabledBackgroundProviders?.length > 0)
const isSavedFilter = computed(() => getSavedFilterIdFromListId(props.list.id) > 0)
</script>

View file

@ -29,60 +29,72 @@
</modal>
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
<script lang="ts">
import {defineComponent, ref} from 'vue'
import Filters from '@/components/list/partials/filters.vue'
import {getDefaultParams} from '@/composables/taskList'
const props = defineProps({
modelValue: {
required: true,
export default defineComponent({
name: 'filter-popup',
components: {
Filters,
},
props: {
modelValue: {
required: true,
},
},
emits: ['update:modelValue'],
computed: {
value: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
},
},
hasFilters() {
// this.value also contains the page parameter which we don't want to include in filters
// eslint-disable-next-line no-unused-vars
const {filter_by, filter_value, filter_comparator, filter_concat, s} = this.value
const def = {...getDefaultParams()}
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
const defaultParams = {
filter_by: def.filter_by,
filter_value: def.filter_value,
filter_comparator: def.filter_comparator,
filter_concat: def.filter_concat,
s: s ? def.s : undefined,
}
return JSON.stringify(params) !== JSON.stringify(defaultParams)
},
},
watch: {
modelValue: {
handler(value) {
this.value = value
},
immediate: true,
},
},
setup() {
const modalOpen = ref(false)
return {
modalOpen,
}
},
methods: {
clearFilters() {
this.value = {...getDefaultParams()}
},
},
})
const emit = defineEmits(['update:modelValue'])
const value = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
},
})
watch(
() => props.modelValue,
(modelValue) => {
value.value = modelValue
},
{immediate: true},
)
const hasFilters = computed(() => {
// this.value also contains the page parameter which we don't want to include in filters
// eslint-disable-next-line no-unused-vars
const {filter_by, filter_value, filter_comparator, filter_concat, s} = value.value
const def = {...getDefaultParams()}
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
const defaultParams = {
filter_by: def.filter_by,
filter_value: def.filter_value,
filter_comparator: def.filter_comparator,
filter_concat: def.filter_concat,
s: s ? def.s : undefined,
}
return JSON.stringify(params) !== JSON.stringify(defaultParams)
})
const modalOpen = ref(false)
function clearFilters() {
value.value = {...getDefaultParams()}
}
</script>
<style scoped lang="scss">

View file

@ -1,25 +1,21 @@
<template>
<card class="filters has-overflow" :title="hasTitle ? $t('filters.title') : ''">
<div class="field is-flex is-flex-direction-column">
<fancycheckbox
v-model="params.filter_include_nulls"
@update:model-value="change()"
>
<div class="field">
<fancycheckbox v-model="params.filter_include_nulls">
{{ $t('filters.attributes.includeNulls') }}
</fancycheckbox>
<fancycheckbox
v-model="filters.requireAllFilters"
@update:model-value="setFilterConcat()"
@change="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
</fancycheckbox>
<fancycheckbox v-model="filters.done" @update:model-value="setDoneFilter">
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
v-if="!$route.name.includes('list.kanban') || !$route.name.includes('list.table')"
v-model="sortAlphabetically"
@update:model-value="change()"
>
{{ $t('filters.attributes.sortAlphabetically') }}
</fancycheckbox>
@ -42,11 +38,11 @@
<priority-select
:disabled="!filters.usePriority || undefined"
v-model.number="filters.priority"
@update:model-value="setPriority"
@change="setPriority"
/>
<fancycheckbox
v-model="filters.usePriority"
@update:model-value="setPriority"
@change="setPriority"
>
{{ $t('filters.attributes.enablePriority') }}
</fancycheckbox>
@ -57,12 +53,12 @@
<div class="control single-value-control">
<percent-done-select
v-model.number="filters.percentDone"
@update:model-value="setPercentDoneFilter"
@change="setPercentDoneFilter"
:disabled="!filters.usePercentDone || undefined"
/>
<fancycheckbox
v-model="filters.usePercentDone"
@update:model-value="setPercentDoneFilter"
@change="setPercentDoneFilter"
>
{{ $t('filters.attributes.enablePercentDone') }}
</fancycheckbox>
@ -72,9 +68,8 @@
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control">
<datepicker-with-range
v-model="filters.dueDate"
@update:model-value="values => setDateFilter('due_date', values)"
>
@dateChanged="values => setDateFilter('due_date', values)"
v-model="filters.dueDate">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
@ -87,9 +82,8 @@
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control">
<datepicker-with-range
v-model="filters.startDate"
@update:model-value="values => setDateFilter('start_date', values)"
>
@dateChanged="values => setDateFilter('start_date', values)"
v-model="filters.startDate">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
@ -102,9 +96,8 @@
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control">
<datepicker-with-range
v-model="filters.endDate"
@update:model-value="values => setDateFilter('end_date', values)"
>
@dateChanged="values => setDateFilter('end_date', values)"
v-model="filters.endDate">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
@ -117,9 +110,8 @@
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control">
<datepicker-with-range
v-model="filters.reminders"
@update:model-value="values => setDateFilter('reminders', values)"
>
@dateChanged="values => setDateFilter('reminders', values)"
v-model="filters.reminders">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
@ -149,7 +141,7 @@
<div class="field">
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<edit-labels v-model="labels" @update:model-value="changeLabelFilter"/>
<edit-labels v-model="labels" @change="changeLabelFilter"/>
</div>
</div>
@ -194,10 +186,8 @@
<script lang="ts">
import {defineComponent} from 'vue'
import {useLabelStore} from '@/stores/labels'
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import DatepickerWithRange from '@/components/date/datepickerWithRange'
import Fancycheckbox from '../../input/fancycheckbox'
import {includesById} from '@/helpers/utils'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
@ -210,7 +200,6 @@ import ListService from '@/services/list'
import NamespaceService from '@/services/namespace'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case'
import {getDefaultParams} from '@/composables/taskList'
import {camelCase} from 'camel-case'
@ -289,7 +278,7 @@ export default defineComponent({
default: false,
},
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'change'],
watch: {
modelValue: {
handler(value) {
@ -314,10 +303,8 @@ export default defineComponent({
this.change()
},
},
foundLabels() {
const labelStore = useLabelStore()
return labelStore.filterLabelsByQuery(this.labels, this.labelQuery)
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
},
},
methods: {
@ -325,6 +312,7 @@ export default defineComponent({
const params = {...this.params}
params.filter_value = params.filter_value.map(v => v instanceof Date ? v.toISOString() : v)
this.$emit('update:modelValue', params)
this.$emit('change', params)
},
prepareFilters() {
this.prepareDone()
@ -345,8 +333,7 @@ export default defineComponent({
: ''
const labelIds = labels.split(',').map(i => parseInt(i))
const labelStore = useLabelStore()
this.labels = labelStore.getLabelsByIds(labelIds)
this.labels = this.$store.getters['labels/getLabelsByIds'](labelIds)
},
removePropertyFromFilter(propertyName) {
// Because of the way arrays work, we can only ever remove one element at once.
@ -392,14 +379,7 @@ export default defineComponent({
this.params.filter_value.push(dateTo)
}
this.filters[camelCase(filterName)] = {
// Passing the dates as string values avoids an endless loop between values changing
// in the datepicker (bubbling up to here) and changing here and bubbling down to the
// datepicker (because there's a new date instance every time this function gets called).
// See https://kolaente.dev/vikunja/frontend/issues/2384
dateFrom: dateIsValid(dateFrom) ? formatISO(dateFrom) : dateFrom,
dateTo: dateIsValid(dateTo) ? formatISO(dateTo) : dateTo,
}
this.filters[camelCase(filterName)] = {dateFrom, dateTo}
this.change()
return
}
@ -520,14 +500,6 @@ export default defineComponent({
return
}
// Don't load things if we already have something loaded.
// This is not the most ideal solution because it prevents a re-population when filters are changed
// from the outside. It is still fine because we're not changing them from the outside, other than
// loading them initially.
if (this[kind].length > 0) {
return
}
this[kind] = await this[`${servicePrefix}Service`].getAll({}, {s: this.filters[filterName]})
},
setDoneFilter() {
@ -546,7 +518,6 @@ export default defineComponent({
} else {
this.params.filter_concat = 'or'
}
this.change()
},
setPriority() {
this.setSingleValueFilter('priority', 'priority', 'usePriority')
@ -561,7 +532,6 @@ export default defineComponent({
if (query === '') {
this.clear(kind)
return
}
const response = await this[`${kind}Service`].getAll({}, {s: query})
@ -586,9 +556,9 @@ export default defineComponent({
return
}
const ids = []
let ids = []
this[kind].forEach(u => {
ids.push(kind === 'users' ? u.username : u.id)
ids.push(u.id)
})
this.filters[filterName] = ids.join(',')
@ -621,7 +591,7 @@ export default defineComponent({
return
}
const labelIDs = []
let labelIDs = []
this.labels.forEach(u => {
labelIDs.push(u.id)
})

View file

@ -1,8 +1,8 @@
<template>
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor) || background !== null,
'has-background': blurHashUrl !== '' || background !== null,
'has-light-text': !colorIsDark(list.hexColor),
'has-background': blurHashUrl !== ''
}"
:style="{
'background-color': list.hexColor,
@ -24,7 +24,7 @@
<BaseButton
v-else
:class="{'is-favorite': list.isFavorite}"
@click.stop="listStore.toggleListFavorite(list)"
@click.stop="toggleFavoriteList(list)"
class="favorite"
>
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/>
@ -36,16 +36,16 @@
</template>
<script lang="ts" setup>
import {type PropType, ref, watch} from 'vue'
import {PropType, ref, watch} from 'vue'
import {useStore} from 'vuex'
import ListService from '@/services/list'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import ListModel from '@/models/list'
import BaseButton from '@/components/base/BaseButton.vue'
import type {IList} from '@/modelTypes/IList'
import {useListStore} from '@/stores/lists'
const background = ref<string | null>(null)
const backgroundLoading = ref(false)
@ -53,7 +53,7 @@ const blurHashUrl = ref('')
const props = defineProps({
list: {
type: Object as PropType<IList>,
type: Object as PropType<ListModel>,
required: true,
},
showArchived: {
@ -84,7 +84,16 @@ async function loadBackground() {
}
}
const listStore = useListStore()
const store = useStore()
function toggleFavoriteList(list: ListModel) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
store.dispatch('lists/toggleListFavorite', list)
}
</script>
<style lang="scss" scoped>
@ -101,7 +110,7 @@ const listStore = useListStore()
overflow: hidden;
&.has-light-text .title {
color: var(--grey-100) !important;
color: var(--grey-100);
}
&.has-background,

View file

@ -9,7 +9,7 @@
</template>
<script lang="ts" setup>
import type {PropType} from 'vue'
import {PropType} from 'vue'
type Variants = 'default' | 'small'
defineProps({

View file

@ -23,14 +23,17 @@
</div>
</div>
<div class="api-url-info" v-else>
<i18n-t keypath="apiConfig.use" scope="global">
<i18n-t keypath="apiConfig.use">
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
</i18n-t>
<br/>
<ButtonLink class="api-config__change-button" @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</ButtonLink>
</div>
<message variant="danger" v-if="errorMsg !== ''" class="mt-2">
<message variant="success" v-if="successMsg !== '' && errorMsg === ''" class="mt-2">
{{ successMsg }}
</message>
<message variant="danger" v-if="errorMsg !== '' && successMsg === ''" class="mt-2">
{{ errorMsg }}
</message>
</div>
@ -71,6 +74,7 @@ watch(() => props.configureOpen, (value) => {
const {t} = useI18n({useScope: 'global'})
const errorMsg = ref('')
const successMsg = ref('')
async function setApiUrl() {
if (apiUrl.value === '') {
@ -95,6 +99,7 @@ async function setApiUrl() {
emit('foundApi', apiUrl.value)
} catch (e) {
// Still not found, url is still invalid
successMsg.value = ''
errorMsg.value = t('apiConfig.error', {domain: apiDomain.value})
}
}

View file

@ -16,21 +16,11 @@
</span>
</BaseButton>
</header>
<div
class="card-content loader-container"
:class="{
'p-0': !padding,
'is-loading': loading
}"
>
<div class="card-content loader-container" :class="{'p-0': !padding, 'is-loading': loading}">
<div :class="{'content': hasContent}">
<slot />
<slot></slot>
</div>
</div>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</div>
</template>
@ -86,11 +76,9 @@ defineEmits(['close'])
border-radius: $radius $radius 0 0;
}
.card-footer {
// FIXME: should maybe be merged somehow with modal
:deep(.modal-card-foot) {
background-color: var(--grey-50);
border-top: 0;
padding: var(--modal-card-head-padding);
display: flex;
justify-content: flex-end;
}
</style>

View file

@ -1,24 +0,0 @@
<template>
<span
:style="{backgroundColor: color }"
class="color-bubble"
></span>
</template>
<script lang="ts" setup>
import type { Color } from 'csstype'
defineProps< {
color: Color,
}>()
</script>
<style scoped>
.color-bubble {
display: inline-block;
border-radius: 100%;
height: 10px;
width: 10px;
flex-shrink: 0;
}
</style>

View file

@ -4,41 +4,38 @@
:title="title"
:shadow="false"
:padding="false"
class="has-text-left"
class="has-text-left has-overflow"
:has-close="true"
@close="$router.back()"
:loading="loading"
>
<div class="p-4">
<slot />
<slot></slot>
</div>
<template #footer>
<slot name="footer">
<x-button
v-if="tertiary !== ''"
:shadow="false"
variant="tertiary"
@click.prevent.stop="$emit('tertiary')"
>
{{ tertiary }}
</x-button>
<x-button
variant="secondary"
@click.prevent.stop="$router.back()"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
variant="primary"
@click.prevent.stop="primary()"
:icon="primaryIcon"
:disabled="primaryDisabled || loading"
>
{{ primaryLabel || $t('misc.create') }}
</x-button>
</slot>
</template>
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
<x-button
v-if="tertiary !== ''"
:shadow="false"
variant="tertiary"
@click.prevent.stop="$emit('tertiary')"
>
{{ tertiary }}
</x-button>
<x-button
variant="secondary"
@click.prevent.stop="$router.back()"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
variant="primary"
@click.prevent.stop="primary()"
:icon="primaryIcon"
:disabled="primaryDisabled"
>
{{ primaryLabel || $t('misc.create') }}
</x-button>
</footer>
</card>
</modal>
</template>

View file

@ -1,6 +1,6 @@
<template>
<message variant="danger">
<i18n-t keypath="loadingError.failed" scope="global">
<i18n-t keypath="loadingError.failed">
<ButtonLink @click="reload">{{ $t('loadingError.tryAgain') }}</ButtonLink>
<ButtonLink href="https://vikunja.io/contact/">{{ $t('loadingError.contact') }}</ButtonLink>
</i18n-t>

View file

@ -33,15 +33,18 @@
</template>
<script lang="ts" setup>
import {useBaseStore} from '@/stores/base'
import {useStore} from 'vuex'
import Shortcut from '@/components/misc/shortcut.vue'
import Message from '@/components/misc/message.vue'
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
import {KEYBOARD_SHORTCUTS as shortcuts} from './shortcuts'
const store = useStore()
function close() {
useBaseStore().setKeyboardShortcutsActive(false)
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
}
</script>

View file

@ -136,10 +136,6 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
title: 'keyboardShortcuts.task.reminder',
keys: ['alt', 'r'],
},
{
title: 'keyboardShortcuts.task.description',
keys: ['e'],
},
],
},
]

View file

@ -8,13 +8,14 @@
<script lang="ts" setup>
import {computed} from 'vue'
import {useStore} from 'vuex'
import BaseButton from '@/components/base/BaseButton.vue'
import {useConfigStore} from '@/stores/config'
const configStore = useConfigStore()
const store = useStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
</script>
<style lang="scss" scoped>

View file

@ -7,7 +7,7 @@
</template>
<script lang="ts" setup>
import {computed, type PropType} from 'vue'
import {computed, PropType} from 'vue'
const TEXT_ALIGN_MAP = Object.freeze({
left: '',

View file

@ -71,9 +71,10 @@ export default {
<script lang="ts" setup>
import BaseButton from '@/components/base/BaseButton.vue'
import {ref, useAttrs, watchEffect} from 'vue'
import {onUnmounted, ref, useAttrs, watch} from 'vue'
import {useScrollLock} from '@vueuse/core'
const props = withDefaults(defineProps<{
enabled?: boolean,
overflow?: boolean,
@ -93,9 +94,14 @@ const attrs = useAttrs()
const modal = ref<HTMLElement | null>(null)
const scrollLock = useScrollLock(modal)
watchEffect(() => {
scrollLock.value = props.enabled
})
watch(
() => props.enabled,
enabled => {
scrollLock.value = enabled
},
)
onUnmounted(() => scrollLock.value = false)
</script>
<style lang="scss" scoped>

View file

@ -26,23 +26,22 @@
</template>
<script lang="ts" setup>
import {computed} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import Logo from '@/components/home/Logo.vue'
import Message from '@/components/misc/message.vue'
import Legal from '@/components/misc/legal.vue'
import ApiConfig from '@/components/misc/api-config.vue'
import {useStore} from 'vuex'
import {computed} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import {useConfigStore} from '@/stores/config'
const configStore = useConfigStore()
const motd = computed(() => configStore.motd)
const route = useRoute()
const store = useStore()
const {t} = useI18n({useScope: 'global'})
const motd = computed(() => store.state.config.motd)
const title = computed(() => t(route.meta?.title as string || ''))
useTitle(() => title.value)
</script>

View file

@ -42,7 +42,7 @@
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useRouter, useRoute} from 'vue-router'
import {useStore} from 'vuex'
import Logo from '@/assets/logo.svg?component'
import ApiConfig from '@/components/misc/api-config.vue'
@ -52,14 +52,13 @@ import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
import {useOnline} from '@/composables/useOnline'
import {useRouter, useRoute} from 'vue-router'
import {getAuthForRoute} from '@/router'
import {useBaseStore} from '@/stores/base'
const router = useRouter()
const route = useRoute()
const baseStore = useBaseStore()
const store = useStore()
const ready = ref(false)
const online = useOnline()
@ -69,14 +68,14 @@ const showLoading = computed(() => !ready.value && error.value === '')
async function load() {
try {
await baseStore.loadApp()
await store.dispatch('loadApp')
const redirectTo = getAuthForRoute(route)
if (typeof redirectTo !== 'undefined') {
await router.push(redirectTo)
}
ready.value = true
} catch (e: unknown) {
error.value = String(e)
} catch (e: any) {
error.value = e
}
}

View file

@ -5,7 +5,7 @@
:icon="iconName"
v-tooltip="tooltipText"
@click="changeSubscription"
:disabled="disabled"
:disabled="disabled || undefined"
>
{{ buttonText }}
</x-button>
@ -23,7 +23,6 @@
v-tooltip="tooltipText"
@click="changeSubscription"
:class="{'is-disabled': disabled}"
:disabled="disabled"
>
<span class="icon">
<icon :icon="iconName"/>
@ -33,7 +32,7 @@
</template>
<script lang="ts" setup>
import {computed, shallowRef, type PropType} from 'vue'
import {computed, shallowRef} from 'vue'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
@ -41,78 +40,57 @@ import DropdownItem from '@/components/misc/dropdown-item.vue'
import SubscriptionService from '@/services/subscription'
import SubscriptionModel from '@/models/subscription'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {success} from '@/message'
const props = defineProps({
entity: String,
entityId: Number,
isButton: {
type: Boolean,
default: true,
},
modelValue: {
type: Object as PropType<ISubscription>,
default: null,
},
type: {
type: String as PropType<'button' | 'dropdown' | 'null'>,
default: 'button',
},
interface Props {
entity: string
entityId: number
subscription: SubscriptionModel | null
type?: 'button' | 'dropdown' | null
}
const props = withDefaults(defineProps<Props>(), {
subscription: null,
type: 'button',
})
const subscriptionEntity = computed<string | null>(() => props.modelValue?.entity ?? null)
const subscriptionEntity = computed<string | null>(() => props.subscription?.entity ?? null)
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['change'])
const subscriptionService = shallowRef(new SubscriptionService())
const {t} = useI18n({useScope: 'global'})
const tooltipText = computed(() => {
if (disabled.value) {
if (props.entity === 'list' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedListThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedTaskThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'list') {
return t('task.subscription.subscribedTaskThroughParentList')
}
return ''
return t('task.subscription.subscribedThroughParent', {
entity: props.entity,
parent: subscriptionEntity.value,
})
}
switch (props.entity) {
case 'namespace':
return props.modelValue !== null ?
t('task.subscription.subscribedNamespace') :
t('task.subscription.notSubscribedNamespace')
case 'list':
return props.modelValue !== null ?
t('task.subscription.subscribedList') :
t('task.subscription.notSubscribedList')
case 'task':
return props.modelValue !== null ?
t('task.subscription.subscribedTask') :
t('task.subscription.notSubscribedTask')
}
return ''
return props.subscription !== null ?
t('task.subscription.subscribed', {entity: props.entity}) :
t('task.subscription.notSubscribed', {entity: props.entity})
})
const buttonText = computed(() => props.modelValue ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
const iconName = computed(() => props.modelValue ? ['far', 'bell-slash'] : 'bell')
const disabled = computed(() => props.modelValue && subscriptionEntity.value !== props.entity)
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
const iconName = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
const disabled = computed(() => {
if (props.subscription === null) {
return false
}
return subscriptionEntity.value !== props.entity
})
function changeSubscription() {
if (disabled.value) {
return
}
if (props.modelValue === null) {
if (props.subscription === null) {
subscribe()
} else {
unsubscribe()
@ -125,21 +103,8 @@ async function subscribe() {
entityId: props.entityId,
})
await subscriptionService.value.create(subscription)
emit('update:modelValue', subscription)
let message = ''
switch (props.entity) {
case 'namespace':
message = t('task.subscription.subscribeSuccessNamespace')
break
case 'list':
message = t('task.subscription.subscribeSuccessList')
break
case 'task':
message = t('task.subscription.subscribeSuccessTask')
break
}
success({message})
emit('change', subscription)
success({message: t('task.subscription.subscribeSuccess', {entity: props.entity})})
}
async function unsubscribe() {
@ -148,20 +113,7 @@ async function unsubscribe() {
entityId: props.entityId,
})
await subscriptionService.value.delete(subscription)
emit('update:modelValue', null)
let message = ''
switch (props.entity) {
case 'namespace':
message = t('task.subscription.unsubscribeSuccessNamespace')
break
case 'list':
message = t('task.subscription.unsubscribeSuccessList')
break
case 'task':
message = t('task.subscription.unsubscribeSuccessTask')
break
}
success({message})
emit('change', null)
success({message: t('task.subscription.unsubscribeSuccess', {entity: props.entity})})
}
</script>

View file

@ -2,39 +2,34 @@
<div :class="{'is-inline': isInline}" class="user">
<img
:height="avatarSize"
:src="getAvatarUrl(user, avatarSize)"
:src="user.getAvatarUrl(avatarSize)"
:width="avatarSize"
alt=""
class="avatar"
v-tooltip="getDisplayName(user)"/>
<span class="username" v-if="showUsername">{{ getDisplayName(user) }}</span>
v-tooltip="user.getDisplayName()"/>
<span class="username" v-if="showUsername">{{ user.getDisplayName() }}</span>
</div>
</template>
<script lang="ts" setup>
import type {PropType} from 'vue'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
defineProps({
user: {
type: Object as PropType<IUser>,
required: true,
type: Object,
},
showUsername: {
type: Boolean,
required: false,
type: Boolean,
default: true,
},
avatarSize: {
type: Number,
required: false,
type: Number,
default: 50,
},
isInline: {
type: Boolean,
required: false,
type: Boolean,
default: false,
},
})

View file

@ -33,13 +33,13 @@
>
{{ $t('menu.archive') }}
</dropdown-item>
<Subscription
<task-subscription
class="has-no-shadow"
:is-button="false"
entity="namespace"
:entity-id="namespace.id"
:model-value="subscription"
@update:model-value="setSubscriptionInStore"
:subscription="subscription"
@change="sub => subscription = sub"
type="dropdown"
/>
<dropdown-item
@ -54,36 +54,21 @@
</template>
<script setup lang="ts">
import {ref, onMounted, type PropType} from 'vue'
import {ref, onMounted} from 'vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Subscription from '@/components/misc/subscription.vue'
import type {INamespace} from '@/modelTypes/INamespace'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {useNamespaceStore} from '@/stores/namespaces'
import TaskSubscription from '@/components/misc/subscription.vue'
const props = defineProps({
namespace: {
type: Object as PropType<INamespace>,
type: Object, // NamespaceModel
required: true,
},
})
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
const subscription = ref(null)
onMounted(() => {
subscription.value = props.namespace.subscription
})
function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub
namespaceStore.setNamespaces([
{
...props.namespace,
subscription: sub,
},
])
}
</script>

View file

@ -24,13 +24,13 @@
<div class="detail">
<div>
<span class="has-text-weight-bold mr-1" v-if="n.notification.doer">
{{ getDisplayName(n.notification.doer) }}
{{ n.notification.doer.getDisplayName() }}
</span>
<BaseButton @click="() => to(n, index)()">
{{ n.toText(userInfo) }}
</BaseButton>
</div>
<span class="created" v-tooltip="formatDateLong(n.created)">
<span class="created" v-tooltip="formatDate(n.created)">
{{ formatDateSince(n.created) }}
</span>
</div>
@ -48,23 +48,21 @@
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import {useRouter} from 'vue-router'
import NotificationService from '@/services/notification'
import BaseButton from '@/components/base/BaseButton.vue'
import User from '@/components/misc/user.vue'
import { NOTIFICATION_NAMES as names, type INotification} from '@/modelTypes/INotification'
import names from '@/models/constants/notificationNames.json'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {useAuthStore} from '@/stores/auth'
import {useStore} from 'vuex'
import {useRouter} from 'vue-router'
const LOAD_NOTIFICATIONS_INTERVAL = 10000
const authStore = useAuthStore()
const store = useStore()
const router = useRouter()
const allNotifications = ref<INotification[]>([])
const allNotifications = ref([])
const showNotifications = ref(false)
const popup = ref(null)
@ -74,7 +72,7 @@ const unreadNotifications = computed(() => {
const notifications = computed(() => {
return allNotifications.value ? allNotifications.value.filter(n => n.name !== '') : []
})
const userInfo = computed(() => authStore.info)
const userInfo = computed(() => store.state.auth.info)
let interval: number

View file

@ -61,6 +61,7 @@ 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 BaseButton from '@/components/base/BaseButton.vue'
@ -70,12 +71,6 @@ import {parseTaskText, PrefixMode} from '@/modules/parseTaskText'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {PREFIXES} from '@/modules/parseTaskText'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
const TYPE_LIST = 'list'
const TYPE_TASK = 'task'
const TYPE_CMD = 'cmd'
@ -113,18 +108,14 @@ export default defineComponent({
},
computed: {
active() {
const active = useBaseStore().quickActionsActive
const active = this.$store.state[QUICK_ACTIONS_ACTIVE]
if (!active) {
// FIXME: computeds shouldn't have side effects.
// create a watcher instead
this.reset()
}
return active
},
results() {
let lists = []
const listStore = useListStore()
if (this.searchMode === SEARCH_MODE_ALL || this.searchMode === SEARCH_MODE_LISTS) {
const {list} = this.parsedQuery
@ -135,8 +126,10 @@ export default defineComponent({
const history = getHistory()
// Puts recently visited lists at the top
const allLists = [...new Set([
...history.map(l => listStore.getListById(l.id)),
...listStore.searchList(list),
...history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
}),
...this.$store.getters['lists/searchList'](list),
])]
lists = allLists.filter(l => {
@ -145,7 +138,7 @@ export default defineComponent({
}
if (typeof ncache[l.namespaceId] === 'undefined') {
ncache[l.namespaceId] = useNamespaceStore().getNamespaceById(l.namespaceId)
ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId)
}
return !ncache[l.namespaceId].isArchived
@ -184,7 +177,8 @@ export default defineComponent({
},
loading() {
return this.taskService.loading ||
useNamespaceStore().isLoading || useListStore().isLoading ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'namespaces') ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'lists') ||
this.teamService.loading
},
placeholder() {
@ -211,7 +205,7 @@ export default defineComponent({
case CMD_NEW_TASK:
return this.$t('quickActions.createTask', {title: this.currentList.title})
case CMD_NEW_LIST:
namespace = useNamespaceStore().getNamespaceById(this.currentList.namespaceId)
namespace = this.$store.getters['namespaces/getNamespaceById'](this.currentList.namespaceId)
return this.$t('quickActions.createList', {title: namespace.title})
}
}
@ -221,8 +215,7 @@ export default defineComponent({
return this.$t('quickActions.hint', prefixes)
},
currentList() {
const currentList = useBaseStore().currentList
return Object.keys(currentList).length === 0 ? null : currentList
return Object.keys(this.$store.state[CURRENT_LIST]).length === 0 ? null : this.$store.state[CURRENT_LIST]
},
availableCmds() {
const cmds = []
@ -303,10 +296,8 @@ export default defineComponent({
filter_comparator: [],
}
const listStore = useListStore()
if (list !== null) {
const l = listStore.findListByExactname(list)
const l = this.$store.getters['lists/findListByExactname'](list)
if (l !== null) {
params.filter_by.push('list_id')
params.filter_value.push(l.id)
@ -315,7 +306,7 @@ export default defineComponent({
}
if (labels.length > 0) {
const labelIds = useLabelStore().getLabelsByExactTitles(labels).map(l => l.id)
const labelIds = this.$store.getters['labels/getLabelsByExactTitles'](labels).map(l => l.id)
if (labelIds.length > 0) {
params.filter_by.push('labels')
params.filter_value.push(labelIds.join())
@ -327,7 +318,7 @@ export default defineComponent({
const r = await this.taskService.getAll({}, params)
this.foundTasks = r.map(t => {
t.type = TYPE_TASK
const list = listStore.getListById(t.listId)
const list = this.$store.getters['lists/getListById'](t.listId)
if (list !== null) {
t.title = `${t.title} (${list.title})`
}
@ -363,7 +354,7 @@ export default defineComponent({
}, 150)
},
closeQuickActions() {
useBaseStore().setQuickActionsActive(false)
this.$store.commit(QUICK_ACTIONS_ACTIVE, false)
},
doAction(type, item) {
switch (type) {
@ -416,8 +407,7 @@ export default defineComponent({
return
}
const taskStore = useTaskStore()
const task = await taskStore.createNewTask({
const task = await this.$store.dispatch('tasks/createNewTask', {
title: this.query,
listId: this.currentList.id,
})
@ -434,8 +424,7 @@ export default defineComponent({
title: this.query,
namespaceId: this.currentList.namespaceId,
})
const listStore = useListStore()
const list = await listStore.createList(newList)
const list = await this.$store.dispatch('lists/createList', newList)
this.$message.success({message: this.$t('list.create.createdSuccess')})
this.$router.push({name: 'list.index', params: {listId: list.id}})
this.closeQuickActions()
@ -443,7 +432,7 @@ export default defineComponent({
async newNamespace() {
const newNamespace = new NamespaceModel({title: this.query})
await useNamespaceStore().createNamespace(newNamespace)
await this.$store.dispatch('namespaces/createNamespace', newNamespace)
this.$message.success({message: this.$t('namespace.create.success')})
this.closeQuickActions()
},

View file

@ -79,59 +79,30 @@
>
<thead>
<tr>
<th></th>
<th>{{ $t('list.share.links.view') }}</th>
<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>
</tr>
</thead>
<tbody>
<tr :key="s.id" v-for="s in linkShares">
<td>
<p class="mb-2 is-italic" v-if="s.name !== ''">
{{ s.name }}
</p>
<p class="mb-2">
<i18n-t keypath="list.share.links.sharedBy" scope="global">
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t>
</p>
<p class="mb-2">
<template v-if="s.right === RIGHTS.ADMIN">
<span class="icon is-small">
<icon icon="lock"/>
</span>&nbsp;
{{ $t('list.share.right.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') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>&nbsp;
{{ $t('list.share.right.read') }}
</template>
</p>
<div class="field has-addons no-input-mobile">
<div class="control">
<input
:value="getShareLink(s.hash, selectedView[s.id])"
class="input"
readonly
type="text"
:value="getShareLink(s.hash)"
class="input"
readonly
type="text"
/>
</div>
<div class="control">
<x-button
@click="copy(getShareLink(s.hash, selectedView[s.id]))"
:shadow="false"
v-tooltip="$t('misc.copy')"
@click="copy(getShareLink(s.hash))"
:shadow="false"
v-tooltip="$t('misc.copy')"
>
<span class="icon">
<icon icon="paste"/>
@ -141,16 +112,33 @@
</div>
</td>
<td>
<div class="select">
<select v-model="selectedView[s.id]">
<option
v-for="(title, key) in availableViews"
:value="key"
:key="key">
{{ title }}
</option>
</select>
</div>
<template v-if="s.name !== ''">
{{ s.name }}
</template>
<i v-else>{{ $t('list.share.links.noName') }}</i>
</td>
<td>
{{ s.sharedBy.getDisplayName() }}
</td>
<td class="type">
<template v-if="s.right === RIGHTS.ADMIN">
<span class="icon is-small">
<icon icon="lock"/>
</span>&nbsp;
{{ $t('list.share.right.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') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>&nbsp;
{{ $t('list.share.right.read') }}
</template>
</td>
<td class="actions">
<x-button
@ -189,22 +177,16 @@
<script setup lang="ts">
import {ref, watch, computed, shallowReactive} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import {RIGHTS} from '@/constants/rights'
import RIGHTS from '@/models/constants/rights.json'
import LinkShareModel from '@/models/linkShare'
import type {ILinkShare} from '@/modelTypes/ILinkShare'
import type {IList} from '@/modelTypes/IList'
import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
import {getDisplayName} from '@/models/user'
import type {ListView} from '@/types/ListView'
import {LIST_VIEWS} from '@/types/ListView'
import {useConfigStore} from '@/stores/config'
const props = defineProps({
listId: {
@ -215,7 +197,7 @@ const props = defineProps({
const {t} = useI18n({useScope: 'global'})
const linkShares = ref<ILinkShare[]>([])
const linkShares = ref([])
const linkShareService = shallowReactive(new LinkShareService())
const selectedRight = ref(RIGHTS.READ)
const name = ref('')
@ -224,17 +206,6 @@ const showDeleteModal = ref(false)
const linkIdToDelete = ref(0)
const showNewForm = ref(false)
type SelectedViewMapper = Record<IList['id'], ListView>
const selectedView = ref<SelectedViewMapper>({})
const availableViews = computed<Record<ListView, string>>(() => ({
list: t('list.list.title'),
gantt: t('list.gantt.title'),
table: t('list.table.title'),
kanban: t('list.kanban.title'),
}))
const copy = useCopyToClipboard()
watch(
() => props.listId,
@ -242,23 +213,19 @@ watch(
{immediate: true},
)
const configStore = useConfigStore()
const frontendUrl = computed(() => configStore.frontendUrl)
const store = useStore()
const frontendUrl = computed(() => store.state.config.frontendUrl)
async function load(listId: IList['id']) {
async function load(listId) {
// If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here
if (listId === 0) {
return
}
const links = await linkShareService.getAll({listId})
links.forEach((l: ILinkShare) => {
selectedView.value[l.id] = 'list'
})
linkShares.value = links
linkShares.value = await linkShareService.getAll({listId})
}
async function add(listId: IList['id']) {
async function add(listId) {
const newLinkShare = new LinkShareModel({
right: selectedRight.value,
listId,
@ -274,7 +241,7 @@ async function add(listId: IList['id']) {
await load(listId)
}
async function remove(listId: IList['id']) {
async function remove(listId) {
try {
await linkShareService.delete(new LinkShareModel({
id: linkIdToDelete.value,
@ -287,15 +254,15 @@ async function remove(listId: IList['id']) {
}
}
function getShareLink(hash: string, view: ListView = LIST_VIEWS.LIST) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
function getShareLink(hash: string) {
return frontendUrl.value + 'share/' + hash + '/auth'
}
</script>
<style lang="scss" scoped>
// FIXME: I think this is not needed
.sharables-list:not(.card-content) {
overflow-y: auto
overflow-y: auto
}
@include modal-transition();

View file

@ -28,7 +28,7 @@
<tbody>
<tr :key="s.id" v-for="s in sharables">
<template v-if="shareType === 'user'">
<td>{{ getDisplayName(s) }}</td>
<td>{{ s.getDisplayName() }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('list.share.userTeam.you') }}</b>
@ -133,44 +133,35 @@
</template>
<script lang="ts">
export default {name: 'userTeamShare'}
import {defineComponent} from 'vue'
export default defineComponent({name: 'userTeamShare'})
</script>
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, type Ref} from 'vue'
import {ref, reactive, computed, shallowReactive, ShallowReactive, Ref} from 'vue'
import type {PropType} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import UserNamespaceService from '@/services/userNamespace'
import UserNamespaceModel from '@/models/userNamespace'
import type {IUserNamespace} from '@/modelTypes/IUserNamespace'
import UserListService from '@/services/userList'
import UserListModel from '@/models/userList'
import type {IUserList} from '@/modelTypes/IUserList'
import UserListService from '@/services/userList'
import UserService from '@/services/user'
import UserModel, { getDisplayName } from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
import UserModel from '@/models/user'
import TeamNamespaceService from '@/services/teamNamespace'
import TeamNamespaceModel from '@/models/teamNamespace'
import type { ITeamNamespace } from '@/modelTypes/ITeamNamespace'
import TeamListService from '@/services/teamList'
import TeamListModel from '@/models/teamList'
import type { ITeamList } from '@/modelTypes/ITeamList'
import TeamListService from '@/services/teamList'
import TeamService from '@/services/team'
import TeamModel from '@/models/team'
import type {ITeam} from '@/modelTypes/ITeam'
import {RIGHTS} from '@/constants/rights'
import RIGHTS from '@/models/constants/rights.json'
import Multiselect from '@/components/input/multiselect.vue'
import Nothing from '@/components/misc/nothing.vue'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
const props = defineProps({
type: {
@ -194,10 +185,10 @@ const props = defineProps({
const {t} = useI18n({useScope: 'global'})
// This user service is either a userNamespaceService or a userListService, depending on the type we are using
let stuffService: UserNamespaceService | UserListService | TeamListService | TeamNamespaceService
let stuffModel: IUserNamespace | IUserList | ITeamList | ITeamNamespace
let searchService: UserService | TeamService
let sharable: Ref<IUser | ITeam>
let stuffService: ShallowReactive<UserNamespaceService | UserListService | TeamListService | TeamNamespaceService>
let stuffModel: UserNamespaceModel | UserListModel | TeamListModel | TeamNamespaceModel
let searchService: ShallowReactive<UserService | TeamService>
let sharable: Ref<UserModel | TeamModel>
const searchLabel = ref('')
const selectedRight = ref({})
@ -208,8 +199,8 @@ const sharables = ref([])
const showDeleteModal = ref(false)
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const store = useStore()
const userInfo = computed(() => store.state.auth.info)
function createShareTypeNameComputed(count: number) {
return computed(() => {
@ -242,7 +233,6 @@ const sharableName = computed(() => {
if (props.shareType === 'user') {
searchService = shallowReactive(new UserService())
// eslint-disable-next-line vue/no-ref-as-operand
sharable = ref(new UserModel())
searchLabel.value = 'username'
@ -259,7 +249,6 @@ if (props.shareType === 'user') {
}
} else if (props.shareType === 'team') {
searchService = new TeamService()
// eslint-disable-next-line vue/no-ref-as-operand
sharable = ref(new TeamModel())
searchLabel.value = 'name'
@ -365,8 +354,8 @@ async function toggleType(sharable) {
const found = ref([])
const currentUserId = computed(() => authStore.info.id)
async function find(query: string) {
const currentUserId = computed(() => store.state.auth.info.id)
async function find(query) {
if (query === '') {
found.value = []
return

View file

@ -3,7 +3,7 @@
<div class="field is-grouped">
<p class="control has-icons-left is-expanded">
<textarea
:disabled="loading || undefined"
:disabled="taskService.loading || undefined"
class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('list.list.addPlaceholder')"
@ -21,10 +21,10 @@
<p class="control">
<x-button
class="add-task-button"
:disabled="newTaskTitle === '' || loading || undefined"
:disabled="newTaskTitle === '' || taskService.loading || undefined"
@click="addTask()"
icon="plus"
:loading="loading"
:loading="taskService.loading"
:aria-label="$t('list.list.add')"
>
<span class="button-text">
@ -41,18 +41,17 @@
</template>
<script setup lang="ts">
import {computed, ref, unref, watch} from 'vue'
import {ref, watch, unref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {debouncedWatch, type MaybeRef, tryOnMounted, useWindowSize} from '@vueuse/core'
import {useStore} from 'vuex'
import {tryOnMounted, debouncedWatch, useWindowSize, MaybeRef} from '@vueuse/core'
import TaskService from '@/services/task'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import type {ITask} from '@/modelTypes/ITask'
import {parseSubtasksViaIndention} from '@/helpers/parseSubtasksViaIndention'
import TaskRelationService from '@/services/taskRelation'
import TaskRelationModel from '@/models/taskRelation'
import {RELATION_KIND} from '@/types/IRelationKind'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
function cleanupTitle(title: string) {
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
}
function useAutoHeightTextarea(value: MaybeRef<string>) {
const textarea = ref<HTMLInputElement>()
@ -135,9 +134,9 @@ const newTaskTitle = ref('')
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const taskStore = useTaskStore()
const store = useStore()
const taskService = shallowReactive(new TaskService())
const errorMessage = ref('')
function resetEmptyTitleError(e) {
@ -149,7 +148,6 @@ function resetEmptyTitleError(e) {
}
}
const loading = computed(() => taskStore.isLoading)
async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('list.create.addTitleRequired')
@ -157,64 +155,30 @@ async function addTask() {
}
errorMessage.value = ''
if (loading.value) {
if (taskService.loading) {
return
}
const taskTitleBackup = newTaskTitle.value
// This allows us to find the tasks with the title they had before being parsed
// by quick add magic.
const createdTasks: { [key: ITask['title']]: ITask } = {}
const tasksToCreate = parseSubtasksViaIndention(newTaskTitle.value)
const newTasks = tasksToCreate.map(async ({title}) => {
const newTasks = newTaskTitle.value.split(/[\r\n]+/).map(async uncleanedTitle => {
const title = cleanupTitle(uncleanedTitle)
if (title === '') {
return
}
const task = await taskStore.createNewTask({
const task = await store.dispatch('tasks/createNewTask', {
title,
listId: authStore.settings.defaultListId,
listId: store.state.auth.settings.defaultListId,
position: props.defaultPosition,
})
createdTasks[title] = task
emit('taskAdded', task)
return task
})
try {
newTaskTitle.value = ''
await Promise.all(newTasks)
const taskRelationService = new TaskRelationService()
const relations = tasksToCreate.map(async t => {
const createdTask = createdTasks[t.title]
if (typeof createdTask === 'undefined') {
return
}
if (t.parent === null) {
emit('taskAdded', createdTask)
return
}
const createdParentTask = createdTasks[t.parent]
if (typeof createdTask === 'undefined' || typeof createdParentTask === 'undefined') {
return
}
const rel = await taskRelationService.create(new TaskRelationModel({
taskId: createdTask.id,
otherTaskId: createdParentTask.id,
relationKind: RELATION_KIND.PARENTTASK,
}))
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [createdParentTask]
// we're only emitting here so that the relation shows up in the task list
emit('taskAdded', createdTask)
return rel
})
await Promise.all(relations)
} catch (e: { message?: string }) {
} catch (e: any) {
newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_LIST') {
errorMessage.value = t('list.create.addListRequired')
@ -236,7 +200,7 @@ function handleEnter(e: KeyboardEvent) {
}
function focusTaskInput() {
newTaskInput.value?.focus()
newTaskInput.value.focus()
}
defineExpose({
@ -250,7 +214,7 @@ defineExpose({
}
.add-task-button {
height: 100% !important;
height: 2.5rem;
@media screen and (max-width: $mobile) {
.button-text {

View file

@ -36,8 +36,8 @@
<strong>{{ $t('task.attributes.reminders') }}</strong>
<reminders
@change="editTaskSubmit()"
v-model="taskEditTask.reminderDates"
@update:model-value="editTaskSubmit()"
/>
<div class="field">
@ -76,7 +76,7 @@
</template>
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, watch, nextTick, type PropType} from 'vue'
import {ref, reactive, computed, shallowReactive, watch, nextTick} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
@ -84,7 +84,6 @@ import Editor from '@/components/input/AsyncEditor'
import TaskService from '@/services/task'
import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import EditLabels from './partials/editLabels.vue'
import Reminders from './partials/reminders.vue'
import ColorPicker from '../input/colorPicker.vue'
@ -96,14 +95,15 @@ const router = useRouter()
const props = defineProps({
task: {
type: Object as PropType<ITask | null>,
type: TaskModel,
required: true,
},
})
const taskService = shallowReactive(new TaskService())
const editorActive = ref(false)
let taskEditTask: ITask | undefined
let taskEditTask: TaskModel | undefined
// FIXME: this initialization should not be necessary here

View file

@ -173,24 +173,21 @@
<script lang="ts">
import {defineComponent} from 'vue'
import {mapState} from 'pinia'
import VueDragResize from 'vue-drag-resize'
import EditTask from './edit-task.vue'
import EditTask from './edit-task'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import {PRIORITIES as priorities} from '@/constants/priorities'
import PriorityLabel from './partials/priorityLabel.vue'
import priorities from '../../models/constants/priorities'
import PriorityLabel from './partials/priorityLabel'
import TaskCollectionService from '../../services/taskCollection'
import {RIGHTS as Rights} from '@/constants/rights'
import {mapState} from 'vuex'
import Rights from '../../models/constants/rights.json'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {formatDate} from '@/helpers/time/formatDate'
import {useBaseStore} from '@/stores/base'
export default defineComponent({
name: 'GanttChart',
@ -258,7 +255,7 @@ export default defineComponent({
mounted() {
this.buildTheGanttChart()
},
computed: mapState(useBaseStore, {
computed: mapState({
canWrite: (state) => state.currentList.maxRight > Rights.READ,
}),
methods: {
@ -278,13 +275,13 @@ export default defineComponent({
prepareGanttDays() {
console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate)
// Layout: years => [months => [days]]
const years = {}
let years = {}
for (
let d = this.startDate;
d <= this.endDate;
d.setDate(d.getDate() + 1)
) {
const date = new Date(d)
let date = new Date(d)
if (years[date.getFullYear() + ''] === undefined) {
years[date.getFullYear() + ''] = {}
}
@ -353,7 +350,7 @@ export default defineComponent({
const didntHaveDates = newTask.startDate === null ? true : false
const startDate = new Date(this.startDate)
let startDate = new Date(this.startDate)
startDate.setDate(
startDate.getDate() + newRect.left / this.dayWidth,
)
@ -362,7 +359,7 @@ export default defineComponent({
startDate.setUTCSeconds(0)
startDate.setUTCMilliseconds(0)
newTask.startDate = startDate
const endDate = new Date(startDate)
let endDate = new Date(startDate)
endDate.setDate(
startDate.getDate() + newRect.width / this.dayWidth,
)
@ -430,7 +427,7 @@ export default defineComponent({
if (!this.newTaskFieldActive) {
return
}
const task = new TaskModel({
let task = new TaskModel({
title: this.newTaskTitle,
listId: this.listId,
})
@ -442,7 +439,7 @@ export default defineComponent({
formatMonthAndYear(year, month) {
month = month < 10 ? '0' + month : month
const date = new Date(`${year}-${month}-01`)
return formatDate(date, 'MMMM, yyyy')
return this.format(date, 'MMMM, yyyy')
},
},
})

View file

@ -8,19 +8,19 @@
</h3>
<input
v-if="editEnabled"
:disabled="loading || undefined"
:disabled="attachmentService.loading || undefined"
@change="uploadNewAttachment()"
id="files"
multiple
ref="filesRef"
ref="files"
type="file"
v-if="editEnabled"
/>
<progress
v-if="attachmentService.uploadProgress > 0"
:value="attachmentService.uploadProgress"
class="progress is-primary"
max="100"
v-if="attachmentService.uploadProgress > 0"
>
{{ attachmentService.uploadProgress }}%
</progress>
@ -35,29 +35,21 @@
:key="a.id"
@click="viewOrDownload(a)"
>
<div class="filename">
{{ a.file.name }}
<span
v-if="task.coverImageAttachmentId === a.id"
class="is-task-cover"
>
{{ $t('task.attachment.usedAsCover') }}
</span>
</div>
<div class="filename">{{ a.file.name }}</div>
<div class="info">
<p class="attachment-info-meta">
<i18n-t keypath="task.attachment.createdBy" scope="global">
<span v-tooltip="formatDateLong(a.created)">
<i18n-t keypath="task.attachment.createdBy">
<span v-tooltip="formatDate(a.created)">
{{ formatDateSince(a.created) }}
</span>
<User
<user
:avatar-size="24"
:user="a.createdBy"
:is-inline="true"
/>
</i18n-t>
<span>
{{ getHumanSize(a.file.size) }}
{{ a.file.getHumanSize() }}
</span>
<span v-if="a.file.mime">
{{ a.file.mime }}
@ -81,22 +73,11 @@
<BaseButton
v-if="editEnabled"
class="attachment-info-meta-button"
@click.prevent.stop="setAttachmentToDelete(a)"
@click.prevent.stop="() => {attachmentToDelete = a; showDeleteModal = true}"
v-tooltip="$t('task.attachment.deleteTooltip')"
>
{{ $t('misc.delete') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
class="attachment-info-meta-button"
@click.prevent.stop="setCoverImage(task.coverImageAttachmentId === a.id ? null : a)"
>
{{
task.coverImageAttachmentId === a.id
? $t('task.attachment.unsetAsCover')
: $t('task.attachment.setAsCover')
}}
</BaseButton>
</p>
</div>
</a>
@ -104,8 +85,8 @@
<x-button
v-if="editEnabled"
:disabled="loading"
@click="filesRef?.click()"
:disabled="attachmentService.loading"
@click="$refs.files.click()"
class="mb-4"
icon="cloud-upload-alt"
variant="secondary"
@ -116,7 +97,7 @@
<!-- Dropzone -->
<div
:class="{ hidden: !isOverDropZone }"
:class="{ hidden: !showDropzone }"
class="dropzone"
v-if="editEnabled"
>
@ -129,242 +110,261 @@
</div>
<!-- Delete modal -->
<modal
v-if="attachmentToDelete !== null"
@close="setAttachmentToDelete(null)"
@submit="deleteAttachment()"
>
<template #header>
<span>{{ $t('task.attachment.delete') }}</span>
</template>
<transition name="modal">
<modal
@close="showDeleteModal = false"
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 class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</p>
</template>
</modal>
<template #text>
<p>
{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br/>
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</p>
</template>
</modal>
</transition>
<!-- Attachment image modal -->
<modal
v-if="attachmentImageBlobUrl !== null"
@close="attachmentImageBlobUrl = null"
>
<img :src="attachmentImageBlobUrl" alt=""/>
</modal>
<transition name="modal">
<modal
@close="
() => {
showImageModal = false
attachmentImageBlobUrl = null
}
"
v-if="showImageModal"
>
<img :src="attachmentImageBlobUrl" alt=""/>
</modal>
</transition>
</div>
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed} from 'vue'
import {useDropZone} from '@vueuse/core'
<script lang="ts">
import {defineComponent} from 'vue'
import User from '@/components/misc/user.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AttachmentService from '../../../services/attachment'
import AttachmentModel from '../../../models/attachment'
import User from '../../misc/user'
import {mapState} from 'vuex'
import AttachmentService from '@/services/attachment'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import type AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {ITask} from '@/modelTypes/ITask'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { uploadFiles, generateAttachmentUrl } from '@/helpers/attachments'
import {useAttachmentStore} from '@/stores/attachments'
import {formatDateSince, formatDateLong} from '@/helpers/time/formatDate'
import {uploadFiles, generateAttachmentUrl} from '@/helpers/attachments'
import {getHumanSize} from '@/helpers/getHumanSize'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton'
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
export default defineComponent({
name: 'attachments',
components: {
BaseButton,
User,
},
data() {
return {
attachmentService: new AttachmentService(),
showDropzone: false,
const props = withDefaults(defineProps<{
task: ITask,
initialAttachments?: IAttachment[],
editEnabled: boolean,
}>(), {
editEnabled: true,
showDeleteModal: false,
attachmentToDelete: AttachmentModel,
showImageModal: false,
attachmentImageBlobUrl: null,
}
},
props: {
taskId: {
required: true,
type: Number,
},
initialAttachments: {
type: Array,
},
editEnabled: {
default: true,
},
},
setup(props) {
const copy = useCopyToClipboard()
function copyUrl(attachment: AttachmentModel) {
copy(generateAttachmentUrl(props.taskId, attachment.id))
}
return { copyUrl }
},
computed: mapState({
attachments: (state) => state.attachments.attachments,
}),
mounted() {
document.addEventListener('dragenter', (e) => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
})
window.addEventListener('dragleave', (e) => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = false
})
document.addEventListener('dragover', (e) => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
})
document.addEventListener('drop', (e) => {
e.stopPropagation()
e.preventDefault()
let files = e.dataTransfer.files
this.uploadFiles(files)
this.showDropzone = false
})
},
methods: {
downloadAttachment(attachment) {
this.attachmentService.download(attachment)
},
uploadNewAttachment() {
if (this.$refs.files.files.length === 0) {
return
}
this.uploadFiles(this.$refs.files.files)
},
uploadFiles(files) {
uploadFiles(this.attachmentService, this.taskId, files)
},
async deleteAttachment() {
try {
const r = await this.attachmentService.delete(this.attachmentToDelete)
this.$store.commit(
'attachments/removeById',
this.attachmentToDelete.id,
)
this.$message.success(r)
} finally{
this.showDeleteModal = false
}
},
async viewOrDownload(attachment) {
if (
attachment.file.name.endsWith('.jpg') ||
attachment.file.name.endsWith('.png') ||
attachment.file.name.endsWith('.bmp') ||
attachment.file.name.endsWith('.gif')
) {
this.showImageModal = true
this.attachmentImageBlobUrl = await this.attachmentService.getBlobUrl(attachment)
} else {
this.downloadAttachment(attachment)
}
},
},
})
// FIXME: this should go through the store
const emit = defineEmits(['task-changed'])
const attachmentService = shallowReactive(new AttachmentService())
const attachmentStore = useAttachmentStore()
const attachments = computed(() => attachmentStore.attachments)
const loading = computed(() => attachmentService.loading || taskStore.isLoading)
function onDrop(files: File[] | null) {
if (files && files.length !== 0) {
uploadFilesToTask(files)
}
}
const {isOverDropZone} = useDropZone(document, onDrop)
function downloadAttachment(attachment: IAttachment) {
attachmentService.download(attachment)
}
const filesRef = ref<HTMLInputElement | null>(null)
function uploadNewAttachment() {
const files = filesRef.value?.files
if (!files || files.length === 0) {
return
}
uploadFilesToTask(files)
}
function uploadFilesToTask(files: File[] | FileList) {
uploadFiles(attachmentService, props.task.id, files)
}
const attachmentToDelete = ref<AttachmentModel | null>(null)
function setAttachmentToDelete(attachment: AttachmentModel | null) {
attachmentToDelete.value = attachment
}
async function deleteAttachment() {
if (attachmentToDelete.value === null) {
return
}
try {
const r = await attachmentService.delete(attachmentToDelete.value)
attachmentStore.removeById(attachmentToDelete.value.id)
success(r)
setAttachmentToDelete(null)
} catch (e) {
error(e)
}
}
const attachmentImageBlobUrl = ref<string | null>(null)
async function viewOrDownload(attachment: AttachmentModel) {
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else {
downloadAttachment(attachment)
}
}
const copy = useCopyToClipboard()
function copyUrl(attachment: IAttachment) {
copy(generateAttachmentUrl(props.task.id, attachment.id))
}
async function setCoverImage(attachment: IAttachment | null) {
const task = await taskStore.setCoverImage(props.task, attachment)
emit('task-changed', task)
success({message: t('task.attachment.successfullyChangedCoverImage')})
}
</script>
<style lang="scss" scoped>
.attachments {
input[type=file] {
display: none;
}
input[type=file] {
display: none;
}
@media screen and (max-width: $tablet) {
.button {
width: 100%;
.files {
margin-bottom: 1rem;
.attachment {
margin-bottom: .5rem;
display: block;
transition: background-color $transition;
border-radius: $radius;
padding: .5rem;
&:hover {
background-color: var(--grey-200);
}
.filename {
font-weight: bold;
margin-bottom: .25rem;
color: var(--text);
}
.info {
color: var(--grey-500);
font-size: .9rem;
p {
margin-bottom: 0;
display: flex;
> span:not(:last-child):after,
> button:not(:last-child):after {
content: '·';
padding: 0 .25rem;
}
}
}
}
}
@media screen and (max-width: $tablet) {
.button {
width: 100%;
}
}
.dropzone {
position: fixed;
background: rgba(250, 250, 250, 0.8);
top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 100;
text-align: center;
&.hidden {
display: none;
}
.drop-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
.icon {
width: 100%;
font-size: 5rem;
height: auto;
text-shadow: var(--shadow-md);
animation: bounce 2s infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
}
.files {
margin-bottom: 1rem;
}
.attachment {
margin-bottom: .5rem;
display: block;
transition: background-color $transition;
border-radius: $radius;
padding: .5rem;
&:hover {
background-color: var(--grey-200);
}
}
.filename {
font-weight: bold;
margin-bottom: .25rem;
color: var(--text);
}
.info {
color: var(--grey-500);
font-size: .9rem;
p {
margin-bottom: 0;
display: flex;
> span:not(:last-child):after,
> button:not(:last-child):after {
content: '·';
padding: 0 .25rem;
}
}
}
.dropzone {
position: fixed;
background: rgba(250, 250, 250, 0.8);
top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 100;
text-align: center;
&.hidden {
display: none;
}
.drop-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
.icon {
width: 100%;
font-size: 5rem;
height: auto;
text-shadow: var(--shadow-md);
animation: bounce 2s infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.hint {
margin: .5rem auto 2rem;
border-radius: 2px;
box-shadow: var(--shadow-md);
background: var(--primary);
padding: 1rem;
color: var(--white);
width: 100%;
max-width: 300px;
}
}
.hint {
margin: .5rem auto 2rem;
border-radius: 2px;
box-shadow: var(--shadow-md);
background: var(--primary);
padding: 1rem;
color: var(--white);
width: 100%;
max-width: 300px;
}
}
}
}
.attachment-info-meta {
@ -401,37 +401,29 @@ async function setCoverImage(attachment: IAttachment | null) {
}
@keyframes bounce {
from,
20%,
53%,
80%,
to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
transform: translate3d(0, 0, 0);
}
from,
20%,
53%,
80%,
to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
transform: translate3d(0, 0, 0);
}
40%,
43% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -30px, 0);
}
40%,
43% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -30px, 0);
}
70% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -15px, 0);
}
70% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -15px, 0);
}
90% {
transform: translate3d(0, -4px, 0);
}
}
.is-task-cover {
background: var(--primary);
color: var(--white);
padding: .25rem .35rem;
border-radius: 4px;
font-size: .75rem;
90% {
transform: translate3d(0, -4px, 0);
}
}
@include modal-transition();

View file

@ -10,15 +10,15 @@
</template>
<script setup lang="ts">
import {computed, type PropType} from 'vue'
import {computed} from 'vue'
import { useI18n } from 'vue-i18n'
import {getChecklistStatistics} from '@/helpers/checklistFromText'
import type {ITask} from '@/modelTypes/ITask'
import TaskModel from '@/models/task'
const props = defineProps({
task: {
type: Object as PropType<ITask>,
type: TaskModel,
required: true,
},
})

View file

@ -17,7 +17,7 @@
<div :key="c.id" class="media comment" v-for="c in comments">
<figure class="media-left is-hidden-mobile">
<img
:src="getAvatarUrl(c.author, 48)"
:src="c.author.getAvatarUrl(48)"
alt=""
class="image is-avatar"
height="48"
@ -27,19 +27,19 @@
<div class="media-content">
<div class="comment-info">
<img
:src="getAvatarUrl(c.author, 20)"
:src="c.author.getAvatarUrl(20)"
alt=""
class="image is-avatar d-print-none"
height="20"
width="20"
/>
<strong>{{ getDisplayName(c.author) }}</strong>&nbsp;
<span v-tooltip="formatDateLong(c.created)" class="has-text-grey">
<strong>{{ c.author.getDisplayName() }}</strong>&nbsp;
<span v-tooltip="formatDate(c.created)" class="has-text-grey">
{{ formatDateSince(c.created) }}
</span>
<span
v-if="+new Date(c.created) !== +new Date(c.updated)"
v-tooltip="formatDateLong(c.updated)"
v-tooltip="formatDate(c.updated)"
>
· {{ $t('task.comment.edited', {date: formatDateSince(c.updated)}) }}
</span>
@ -70,13 +70,13 @@
:is-edit-enabled="canWrite && c.author.id === currentUserId"
:upload-callback="attachmentUpload"
:upload-enabled="true"
v-model="c.comment"
@update:model-value="
@change="
() => {
toggleEdit(c)
editComment()
}
"
v-model="c.comment"
:bottom-actions="actions[c.id]"
:show-save="true"
/>
@ -153,22 +153,15 @@
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, watch, nextTick} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import Editor from '@/components/input/AsyncEditor'
import TaskCommentService from '@/services/taskComment'
import TaskCommentModel from '@/models/taskComment'
import type {ITaskComment} from '@/modelTypes/ITaskComment'
import type {ITask} from '@/modelTypes/ITask'
import {uploadFile} from '@/helpers/attachments'
import {success} from '@/message'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const props = defineProps({
taskId: {
@ -181,10 +174,9 @@ const props = defineProps({
})
const {t} = useI18n({useScope: 'global'})
const configStore = useConfigStore()
const authStore = useAuthStore()
const store = useStore()
const comments = ref<ITaskComment[]>([])
const comments = ref<TaskCommentModel[]>([])
const showDeleteModal = ref(false)
const commentToDelete = reactive(new TaskCommentModel())
@ -194,12 +186,12 @@ const commentEdit = reactive(new TaskCommentModel())
const newComment = reactive(new TaskCommentModel())
const saved = ref<ITask['id'] | null>(null)
const saving = ref<ITask['id'] | null>(null)
const saved = ref(null)
const saving = ref(null)
const userAvatar = computed(() => getAvatarUrl(authStore.info, 48))
const currentUserId = computed(() => authStore.info.id)
const enabled = computed(() => configStore.taskCommentsEnabled)
const userAvatar = computed(() => store.state.auth.info.getAvatarUrl(48))
const currentUserId = computed(() => store.state.auth.info.id)
const enabled = computed(() => store.state.config.taskCommentsEnabled)
const actions = computed(() => {
if (!props.canWrite) {
return {}
@ -215,18 +207,13 @@ const actions = computed(() => {
])))
})
function attachmentUpload(
file: File,
onSuccess: (url: string) => void,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onError: (error: string) => void,
) {
return uploadFile(props.taskId, file, onSuccess)
function attachmentUpload(...args) {
return uploadFile(props.taskId, ...args)
}
const taskCommentService = shallowReactive(new TaskCommentService())
async function loadComments(taskId: ITask['id']) {
async function loadComments(taskId) {
if (!enabled.value) {
return
}
@ -270,12 +257,12 @@ async function addComment() {
}
}
function toggleEdit(comment: ITaskComment) {
function toggleEdit(comment: TaskCommentModel) {
isCommentEdit.value = !isCommentEdit.value
Object.assign(commentEdit, comment)
}
function toggleDelete(commentId: ITaskComment['id']) {
function toggleDelete(commentId) {
showDeleteModal.value = !showDeleteModal.value
commentToDelete.id = commentId
}
@ -305,7 +292,7 @@ async function editComment() {
}
}
async function deleteComment(commentToDelete: ITaskComment) {
async function deleteComment(commentToDelete: TaskCommentModel) {
try {
await taskCommentService.delete(commentToDelete)
const index = comments.value.findIndex(({id}) => id === commentToDelete.id)

View file

@ -1,16 +1,16 @@
<template>
<p class="created">
<time :datetime="formatISO(task.created)" v-tooltip="formatDateLong(task.created)">
<i18n-t keypath="task.detail.created" scope="global">
<time :datetime="formatISO(task.created)" v-tooltip="formatDate(task.created)">
<i18n-t keypath="task.detail.created">
<span>{{ formatDateSince(task.created) }}</span>
{{ getDisplayName(task.createdBy) }}
{{ task.createdBy.getDisplayName() }}
</i18n-t>
</time>
<template v-if="+new Date(task.created) !== +new Date(task.updated)">
<br/>
<!-- Computed properties to show the actual date every time it gets updated -->
<time :datetime="formatISO(task.updated)" v-tooltip="updatedFormatted">
<i18n-t keypath="task.detail.updated" scope="global">
<i18n-t keypath="task.detail.updated">
<span>{{ updatedSince }}</span>
</i18n-t>
</time>
@ -18,7 +18,7 @@
<template v-if="task.done">
<br/>
<time :datetime="formatISO(task.doneAt)" v-tooltip="doneFormatted">
<i18n-t keypath="task.detail.doneAt" scope="global">
<i18n-t keypath="task.detail.doneAt">
<span>{{ doneSince }}</span>
</i18n-t>
</time>
@ -27,14 +27,13 @@
</template>
<script lang="ts" setup>
import {computed, toRefs, type PropType} from 'vue'
import type {ITask} from '@/modelTypes/ITask'
import {formatISO, formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {computed, toRefs} from 'vue'
import TaskModel from '@/models/task'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
const props = defineProps({
task: {
type: Object as PropType<ITask>,
type: TaskModel,
required: true,
},
})

View file

@ -1,14 +1,12 @@
<template>
<td v-tooltip="+date === 0 ? '' : formatDateLong(date)">
<time :datetime="date ? formatISO(date) : undefined">
<td v-tooltip="+date === 0 ? '' : formatDate(date)">
<time :datetime="date ? formatISO(date) : null">
{{ +date === 0 ? '-' : formatDateSince(date) }}
</time>
</td>
</template>
<script setup lang="ts">
import {formatISO, formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
defineProps({
date: {
type: Date,

View file

@ -38,37 +38,37 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount, toRef, type PropType} from 'vue'
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
import TaskService from '@/services/task'
import type {ITask} from '@/modelTypes/ITask'
import {useAuthStore} from '@/stores/auth'
import TaskModel from '@/models/task'
const props = defineProps({
modelValue: {
type: Object as PropType<ITask>,
type: TaskModel,
required: true,
},
})
const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const store = useStore()
const taskService = shallowReactive(new TaskService())
const task = ref<ITask>()
const task = ref<TaskModel>()
// We're saving the due date seperately to prevent null errors in very short periods where the task is null.
const dueDate = ref<Date>()
const lastValue = ref<Date>()
const changeInterval = ref<ReturnType<typeof setInterval>>()
const changeInterval = ref<number>()
watch(
toRef(props, 'modelValue'),
() => props.modelValue,
(value) => {
task.value = { ...value }
task.value = value
dueDate.value = value.dueDate
lastValue.value = value.dueDate
},
@ -103,7 +103,7 @@ const flatPickerConfig = computed(() => ({
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: authStore.settings.weekStart,
firstDayOfWeek: store.state.auth.settings.weekStart,
},
}))
@ -123,10 +123,9 @@ async function updateDueDate() {
return
}
const newTask = await taskService.update({
...task.value,
dueDate: new Date(dueDate.value),
})
// FIXME: direct prop manipulation
task.value.dueDate = new Date(dueDate.value)
const newTask = await taskService.update(task.value)
lastValue.value = newTask.dueDate
task.value = newTask
emit('update:modelValue', newTask)

View file

@ -20,50 +20,47 @@
:is-edit-enabled="canWrite"
:upload-callback="attachmentUpload"
:upload-enabled="true"
@change="save"
:placeholder="$t('task.description.placeholder')"
:empty-text="$t('task.description.empty')"
:show-save="true"
edit-shortcut="e"
v-model="task.description"
@update:model-value="save"
/>
</div>
</template>
<script setup lang="ts">
import {ref,computed, watch, type PropType} from 'vue'
import {ref,computed, watch} from 'vue'
import {useStore} from 'vuex'
import Editor from '@/components/input/AsyncEditor'
import type {ITask} from '@/modelTypes/ITask'
import {useTaskStore} from '@/stores/tasks'
import TaskModel from '@/models/task'
const props = defineProps({
modelValue: {
type: Object as PropType<ITask>,
type: TaskModel,
required: true,
},
attachmentUpload: {
required: true,
},
canWrite: {
type: Boolean,
required: true,
},
})
const emit = defineEmits(['update:modelValue'])
const task = ref<ITask>(new TaskModel())
const task = ref<TaskModel>({description: ''})
const saved = ref(false)
// Since loading is global state, this variable ensures we're only showing the saving icon when saving the description.
const saving = ref(false)
const taskStore = useTaskStore()
const loading = computed(() => taskStore.isLoading)
const store = useStore()
const loading = computed(() => store.state.loading)
watch(
() => props.modelValue,
@ -78,7 +75,7 @@ async function save() {
try {
// FIXME: don't update state from internal.
task.value = await taskStore.update(task.value)
task.value = await store.dispatch('tasks/update', task.value)
emit('update:modelValue', task.value)
saved.value = true

View file

@ -10,7 +10,7 @@
@search="findUser"
:search-results="foundUsers"
@select="addAssignee"
label="name"
label="username"
:select-placeholder="$t('task.assignee.selectPlaceholder')"
v-model="assignees"
ref="multiselect"
@ -28,7 +28,8 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, watch, nextTick, type PropType} from 'vue'
import {ref, shallowReactive, watch, PropType} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import User from '@/components/misc/user.vue'
@ -36,38 +37,35 @@ import Multiselect from '@/components/input/multiselect.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {includesById} from '@/helpers/utils'
import UserModel from '@/models/user'
import ListUserService from '@/services/listUsers'
import {success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import type {IUser} from '@/modelTypes/IUser'
const props = defineProps({
taskId: {
type: Number,
required: true,
},
listId: {
type: Number,
required: true,
},
disabled: {
default: false,
},
modelValue: {
type: Array as PropType<IUser[]>,
default: () => [],
},
})
taskId: {
type: Number,
required: true,
},
listId: {
type: Number,
required: true,
},
disabled: {
default: false,
},
modelValue: {
type: Array as PropType<UserModel[]>,
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const taskStore = useTaskStore()
const store = useStore()
const {t} = useI18n({useScope: 'global'})
const listUserService = shallowReactive(new ListUserService())
const foundUsers = ref([])
const assignees = ref<IUser[]>([])
let isAdding = false
const assignees = ref<UserModel[]>([])
watch(
() => props.modelValue,
@ -80,24 +78,14 @@ watch(
},
)
async function addAssignee(user: IUser) {
if (isAdding) {
return
}
try {
nextTick(() => isAdding = true)
await taskStore.addAssignee({user: user, taskId: props.taskId})
emit('update:modelValue', assignees.value)
success({message: t('task.assignee.assignSuccess')})
} finally {
nextTick(() => isAdding = false)
}
async function addAssignee(user: UserModel) {
await store.dispatch('tasks/addAssignee', {user: user, taskId: props.taskId})
emit('update:modelValue', assignees.value)
success({message: t('task.assignee.assignSuccess')})
}
async function removeAssignee(user: IUser) {
await taskStore.removeAssignee({user: user, taskId: props.taskId})
async function removeAssignee(user: UserModel) {
await store.dispatch('tasks/removeAssignee', {user: user, taskId: props.taskId})
// Remove the assignee from the list
for (const a in assignees.value) {
@ -118,11 +106,6 @@ async function findUser(query: string) {
// Filter the results to not include users who are already assigned
foundUsers.value = response.filter(({id}) => !includesById(assignees.value, id))
.map(u => {
// Users may not have a display name set, so we fall back on the username in that case
u.name = u.name === '' ? u.username : u.name
return u
})
}
function clearAllFoundUsers() {
@ -130,7 +113,6 @@ function clearAllFoundUsers() {
}
const multiselect = ref()
function focus() {
multiselect.value.focus()
}

View file

@ -39,7 +39,8 @@
</template>
<script setup lang="ts">
import {type PropType, ref, computed, shallowReactive, watch} from 'vue'
import {PropType, ref, computed, shallowReactive, watch} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import LabelModel from '@/models/label'
@ -48,13 +49,10 @@ import {success} from '@/message'
import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {ILabel} from '@/modelTypes/ILabel'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
const props = defineProps({
modelValue: {
type: Array as PropType<ILabel[]>,
type: Array as PropType<LabelModel[]>,
default: () => [],
},
taskId: {
@ -67,12 +65,13 @@ const props = defineProps({
},
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'change'])
const store = useStore()
const {t} = useI18n({useScope: 'global'})
const labelTaskService = shallowReactive(new LabelTaskService())
const labels = ref<ILabel[]>([])
const labels = ref<LabelModel[]>([])
const query = ref('')
watch(
@ -86,40 +85,43 @@ watch(
},
)
const taskStore = useTaskStore()
const labelStore = useLabelStore()
const foundLabels = computed(() => labelStore.filterLabelsByQuery(labels.value, query.value))
const loading = computed(() => labelTaskService.loading || labelStore.isLoading)
const foundLabels = computed(() => store.getters['labels/filterLabelsByQuery'](labels.value, query.value))
const loading = computed(() => labelTaskService.loading || (store.state.loading && store.state.loadingModule === 'labels'))
function findLabel(newQuery: string) {
query.value = newQuery
}
async function addLabel(label: ILabel, showNotification = true) {
if (props.taskId === 0) {
async function addLabel(label: LabelModel, showNotification = true) {
const bubble = () => {
emit('update:modelValue', labels.value)
emit('change', labels.value)
}
if (props.taskId === 0) {
bubble()
return
}
await taskStore.addLabel({label, taskId: props.taskId})
emit('update:modelValue', labels.value)
await store.dispatch('tasks/addLabel', {label, taskId: props.taskId})
bubble()
if (showNotification) {
success({message: t('task.label.addSuccess')})
}
}
async function removeLabel(label: ILabel) {
async function removeLabel(label: LabelModel) {
if (props.taskId !== 0) {
await taskStore.removeLabel({label, taskId: props.taskId})
await store.dispatch('tasks/removeLabel', {label, taskId: props.taskId})
}
for (const l in labels.value) {
if (labels.value[l].id === label.id) {
labels.value.splice(l, 1) // FIXME: l should be index
labels.value.splice(l, 1)
}
}
emit('update:modelValue', labels.value)
emit('change', labels.value)
success({message: t('task.label.removeSuccess')})
}
@ -128,8 +130,7 @@ async function createAndAddLabel(title: string) {
return
}
const labelStore = useLabelStore()
const newLabel = await labelStore.createLabel(new LabelModel({title}))
const newLabel = await store.dispatch('labels/createLabel', new LabelModel({title}))
addLabel(newLabel, false)
labels.value.push(newLabel)
success({message: t('task.label.addCreateSuccess')})

View file

@ -1,12 +1,7 @@
<template>
<div class="heading">
<BaseButton @click="copyUrl"><h1 class="title task-id">{{ textIdentifier }}</h1></BaseButton>
<Done class="heading__done" :is-done="task.done"/>
<ColorBubble
v-if="task.hexColor !== ''"
:color="task.getHexColor()"
class="mt-1 ml-2"
/>
<Done class="heading__done" :is-done="task.done" />
<h1
class="title input"
:class="{'disabled': !canWrite}"
@ -37,21 +32,18 @@
</template>
<script setup lang="ts">
import {ref, computed, type PropType} from 'vue'
import {useRouter} from 'vue-router'
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
import BaseButton from '@/components/base/BaseButton.vue'
import ColorBubble from '@/components/misc/colorBubble.vue'
import Done from '@/components/misc/Done.vue'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {useTaskStore} from '@/stores/tasks'
import type {ITask} from '@/modelTypes/ITask'
import TaskModel from '@/models/task'
import { useRouter } from 'vue-router'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const props = defineProps({
task: {
type: Object as PropType<ITask>,
type: TaskModel,
required: true,
},
canWrite: {
@ -64,16 +56,15 @@ const emit = defineEmits(['update:task'])
const router = useRouter()
const copy = useCopyToClipboard()
async function copyUrl() {
const route = router.resolve({name: 'task.detail', query: {taskId: props.task.id}})
const route = router.resolve({ name: 'task.detail', query: { taskId: props.task.id}})
const absoluteURL = new URL(route.href, window.location.href).href
await copy(absoluteURL)
}
const taskStore = useTaskStore()
const loading = computed(() => taskStore.isLoading)
const store = useStore()
const loading = computed(() => store.state.loading)
const textIdentifier = computed(() => props.task?.getTextIdentifier() || '')
@ -93,7 +84,7 @@ async function save(title: string) {
try {
saving.value = true
const newTask = await taskStore.update({
const newTask = await store.dispatch('tasks/update', {
...props.task,
title,
})
@ -102,7 +93,8 @@ async function save(title: string) {
setTimeout(() => {
showSavedMessage.value = false
}, 2000)
} finally {
}
finally {
saving.value = false
}
}
@ -112,9 +104,4 @@ async function save(title: string) {
.heading__done {
margin-left: .5rem;
}
.color-bubble {
height: .75rem;
width: .75rem;
}
</style>

View file

@ -6,147 +6,136 @@
'draggable': !(loadingInternal || loading),
'has-light-text': color !== TASK_DEFAULT_COLOR && !colorIsDark(color),
}"
:style="{'background-color': color !== TASK_DEFAULT_COLOR ? color : undefined}"
:style="{'background-color': color !== TASK_DEFAULT_COLOR ? color : false}"
@click.exact="openTaskDetail()"
@click.ctrl="() => toggleTaskDone(task)"
@click.meta="() => toggleTaskDone(task)"
>
<img
v-if="coverImageBlobUrl"
:src="coverImageBlobUrl"
alt=""
class="cover-image"
/>
<div class="p-2">
<span class="task-id">
<Done class="kanban-card__done" :is-done="task.done" variant="small"/>
<template v-if="task.identifier === ''">
#{{ task.index }}
</template>
<template v-else>
{{ task.identifier }}
</template>
<span class="task-id">
<Done class="kanban-card__done" :is-done="task.done" variant="small" />
<template v-if="task.identifier === ''">
#{{ task.index }}
</template>
<template v-else>
{{ task.identifier }}
</template>
</span>
<span
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
v-if="task.dueDate > 0"
v-tooltip="formatDate(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<span
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }}
</time>
</span>
<h3>{{ task.title }}</h3>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100">
{{ task.percentDone * 100 }}%
</progress>
<div class="footer">
<labels :labels="task.labels"/>
<priority-label :priority="task.priority" :done="task.done"/>
<div class="assignees" v-if="task.assignees.length > 0">
<user
v-for="u in task.assignees"
:avatar-size="24"
:key="task.id + 'assignee' + u.id"
:show-username="false"
:user="u"
/>
</div>
<checklist-summary :task="task"/>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span v-if="task.description" class="icon">
<icon icon="align-left"/>
</span>
<span class="icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
<time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }}
</time>
</span>
<h3>{{ task.title }}</h3>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100">
{{ task.percentDone * 100 }}%
</progress>
<div class="footer">
<labels :labels="task.labels"/>
<priority-label :priority="task.priority" :done="task.done"/>
<div class="assignees" v-if="task.assignees.length > 0">
<user
:avatar-size="24"
:key="task.id + 'assignee' + u.id"
:show-username="false"
:user="u"
v-for="u in task.assignees"
/>
</div>
<checklist-summary :task="task"/>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span v-if="task.description" class="icon">
<icon icon="align-left"/>
</span>
<span class="icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, watch} from 'vue'
import {useRouter} from 'vue-router'
<script lang="ts">
import {defineComponent} from 'vue'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
import User from '@/components/misc/user.vue'
import {playPop} from '../../../helpers/playPop'
import PriorityLabel from '../../../components/tasks/partials/priorityLabel'
import User from '../../../components/misc/user'
import Done from '@/components/misc/Done.vue'
import Labels from '@/components/tasks/partials/labels.vue'
import ChecklistSummary from './checklist-summary.vue'
import Labels from '../../../components/tasks/partials/labels'
import ChecklistSummary from './checklist-summary'
import {TASK_DEFAULT_COLOR} from '@/models/task'
import {TASK_DEFAULT_COLOR, getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import AttachmentService from '@/services/attachment'
import {formatDateLong, formatISO, formatDateSince} from '@/helpers/time/formatDate'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {useTaskStore} from '@/stores/tasks'
const router = useRouter()
const loadingInternal = ref(false)
const props = withDefaults(defineProps<{
task: ITask,
loading: boolean,
}>(), {
loading: false,
export default defineComponent({
name: 'kanban-card',
components: {
ChecklistSummary,
Done,
PriorityLabel,
User,
Labels,
},
data() {
return {
loadingInternal: false,
TASK_DEFAULT_COLOR,
}
},
props: {
task: {
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
color() {
return this.task.getHexColor
? this.task.getHexColor()
: TASK_DEFAULT_COLOR
},
},
methods: {
colorIsDark,
async toggleTaskDone(task) {
this.loadingInternal = true
try {
const done = !task.done
await this.$store.dispatch('tasks/update', {
...task,
done,
})
if (done) {
playPop()
}
} finally {
this.loadingInternal = false
}
},
openTaskDetail() {
this.$router.push({
name: 'task.detail',
params: { id: this.task.id },
state: { backdropView: this.$router.currentRoute.value.fullPath },
})
},
},
})
const color = computed(() => getHexColor(props.task.hexColor))
async function toggleTaskDone(task: ITask) {
loadingInternal.value = true
try {
await useTaskStore().update({
...task,
done: !task.done,
})
} finally {
loadingInternal.value = false
}
}
function openTaskDetail() {
router.push({
name: 'task.detail',
params: {id: props.task.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}
const coverImageBlobUrl = ref<string | null>(null)
async function maybeDownloadCoverImage() {
if (!props.task.coverImageAttachmentId) {
coverImageBlobUrl.value = null
return
}
const attachment = props.task.attachments.find(a => a.id === props.task.coverImageAttachmentId)
if (!attachment || !SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
return
}
const attachmentService = new AttachmentService()
coverImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
}
watch(
() => props.task.coverImageAttachmentId,
maybeDownloadCoverImage,
{immediate: true},
)
</script>
<style lang="scss" scoped>
@ -158,11 +147,12 @@ $task-background: var(--white);
cursor: pointer;
box-shadow: var(--shadow-xs);
display: block;
border: 3px solid transparent;
font-size: .9rem;
padding: .4rem;
border-radius: $radius;
background: $task-background;
overflow: hidden;
&.loader-container.is-loading::after {
width: 1.5rem;

View file

@ -11,12 +11,8 @@
</template>
<script setup lang="ts">
import type {PropType} from 'vue'
import type {ILabel} from '@/modelTypes/ILabel'
defineProps({
labels: {
type: Array as PropType<ILabel[]>,
required: true,
},
})

View file

@ -1,5 +1,5 @@
<template>
<Multiselect
<multiselect
class="control is-expanded"
:placeholder="$t('list.search')"
@search="findLists"
@ -13,30 +13,32 @@
<span class="list-namespace-title search-result">{{ namespace(props.option.namespaceId) }} ></span>
{{ props.option.title }}
</template>
</Multiselect>
</multiselect>
</template>
<script lang="ts" setup>
import {reactive, ref, watch} from 'vue'
import type {PropType} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import ListModel from '@/models/list'
import type {IList} from '@/modelTypes/IList'
import Multiselect from '@/components/input/multiselect.vue'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({
modelValue: {
type: Object as PropType<IList>,
type: Object as PropType<ListModel>,
validator(value) {
return value instanceof ListModel
},
required: false,
},
})
const emit = defineEmits(['update:modelValue'])
const store = useStore()
const {t} = useI18n({useScope: 'global'})
const list: IList = reactive(new ListModel())
const list = reactive<ListModel>(new ListModel())
watch(
() => props.modelValue,
@ -47,26 +49,21 @@ watch(
},
)
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const foundLists = ref<IList[]>([])
const foundLists = ref([])
function findLists(query: string) {
if (query === '') {
select(null)
}
foundLists.value = listStore.searchList(query)
foundLists.value = store.getters['lists/searchList'](query)
}
function select(l: IList | null) {
if (l === null) {
return
}
function select(l: ListModel | null) {
Object.assign(list, l)
emit('update:modelValue', list)
}
function namespace(namespaceId: number) {
const namespace = namespaceStore.getNamespaceById(namespaceId)
const namespace = store.getters['namespaces/getNamespaceById'](namespaceId)
return namespace !== null
? namespace.title
: t('list.shared')

View file

@ -32,12 +32,13 @@ const props = defineProps({
},
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'change'])
const percentDone = computed({
get: () => props.modelValue,
set(percentDone) {
emit('update:modelValue', percentDone)
emit('change')
},
})
</script>

View file

@ -21,7 +21,7 @@
</template>
<script setup lang="ts">
import {PRIORITIES as priorities} from '@/constants/priorities'
import priorities from '@/models/constants/priorities'
defineProps({
priority: {

View file

@ -5,33 +5,33 @@
@change="updateData"
:disabled="disabled || undefined"
>
<option :value="PRIORITIES.UNSET">{{ $t('task.priority.unset') }}</option>
<option :value="PRIORITIES.LOW">{{ $t('task.priority.low') }}</option>
<option :value="PRIORITIES.MEDIUM">{{ $t('task.priority.medium') }}</option>
<option :value="PRIORITIES.HIGH">{{ $t('task.priority.high') }}</option>
<option :value="PRIORITIES.URGENT">{{ $t('task.priority.urgent') }}</option>
<option :value="PRIORITIES.DO_NOW">{{ $t('task.priority.doNow') }}</option>
<option :value="priorities.UNSET">{{ $t('task.priority.unset') }}</option>
<option :value="priorities.LOW">{{ $t('task.priority.low') }}</option>
<option :value="priorities.MEDIUM">{{ $t('task.priority.medium') }}</option>
<option :value="priorities.HIGH">{{ $t('task.priority.high') }}</option>
<option :value="priorities.URGENT">{{ $t('task.priority.urgent') }}</option>
<option :value="priorities.DO_NOW">{{ $t('task.priority.doNow') }}</option>
</select>
</div>
</template>
<script setup lang="ts">
import {ref, watch} from 'vue'
import {PRIORITIES} from '@/constants/priorities'
import priorities from '@/models/constants/priorities.json'
const priority = ref(0)
const props = defineProps({
modelValue: {
type: Number,
default: 0,
type: Number,
},
disabled: {
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const priority = ref(0)
const emit = defineEmits(['update:modelValue', 'change'])
// FIXME: store value outside
// Set the priority to the :value every time it changes from the outside
@ -45,5 +45,6 @@ watch(
function updateData() {
emit('update:modelValue', priority.value)
emit('change')
}
</script>

View file

@ -25,52 +25,49 @@
</transition>
</label>
<div class="field" key="field-search">
<Multiselect
<multiselect
:placeholder="$t('task.relation.searchPlaceholder')"
@search="findTasks"
:loading="taskService.loading"
:search-results="mappedFoundTasks"
label="title"
v-model="newTaskRelation.task"
v-model="newTaskRelationTask"
:creatable="true"
:create-placeholder="$t('task.relation.createPlaceholder')"
@create="createAndRelateTask"
@select="addTaskRelation"
>
<template #searchResult="{option: task}">
<span
v-if="typeof task !== 'string'"
class="search-result"
:class="{'is-strikethrough': task.done}"
>
<template #searchResult="props">
<span v-if="typeof props.option !== 'string'" class="search-result">
<span
class="different-list"
v-if="task.listId !== listId"
v-if="props.option.listId !== listId"
>
<span
v-if="task.differentNamespace !== null"
v-if="props.option.differentNamespace !== null"
v-tooltip="$t('task.relation.differentNamespace')">
{{ task.differentNamespace }} >
{{ props.option.differentNamespace }} >
</span>
<span
v-if="task.differentList !== null"
v-if="props.option.differentList !== null"
v-tooltip="$t('task.relation.differentList')">
{{ task.differentList }} >
{{ props.option.differentList }} >
</span>
</span>
{{ task.title }}
{{ props.option.title }}
</span>
<span class="search-result" v-else>
{{ task }}
{{ props.option }}
</span>
</template>
</Multiselect>
</multiselect>
</div>
<div class="field has-addons mb-4" key="field-kind">
<div class="control is-expanded">
<div class="select is-fullwidth has-defaults">
<select v-model="newTaskRelation.kind">
<select v-model="newTaskRelationKind">
<option value="unset">{{ $t('task.relation.select') }}</option>
<option :key="`option_${rk}`" :value="rk" v-for="rk in RELATION_KINDS">
<option :key="rk" :value="rk" v-for="rk in relationKinds">
{{ $tc(`task.relation.kinds.${rk}`, 1) }}
</option>
</select>
@ -87,40 +84,29 @@
<span class="title">{{ rts.title }}</span>
<div class="tasks">
<div :key="t.id" class="task" v-for="t in rts.tasks">
<div class="is-flex is-align-items-center">
<Fancycheckbox
class="task-done-checkbox"
v-model="t.done"
@update:model-value="toggleTaskDone(t)"
/>
<router-link
:to="{ name: route.name as string, params: { id: t.id } }"
:class="{ 'is-strikethrough': t.done}"
<router-link
:to="{ name: $route.name, params: { id: t.id } }"
:class="{ 'is-strikethrough': t.done}">
<span
class="different-list"
v-if="t.listId !== listId"
>
<span
class="different-list"
v-if="t.listId !== listId"
>
<span
v-if="t.differentNamespace !== null"
v-tooltip="$t('task.relation.differentNamespace')">
{{ t.differentNamespace }} >
</span>
<span
v-if="t.differentList !== null"
v-tooltip="$t('task.relation.differentList')">
{{ t.differentList }} >
</span>
v-if="t.differentNamespace !== null"
v-tooltip="$t('task.relation.differentNamespace')">
{{ t.differentNamespace }} >
</span>
{{ t.title }}
</router-link>
</div>
<span
v-if="t.differentList !== null"
v-tooltip="$t('task.relation.differentList')">
{{ t.differentList }} >
</span>
</span>
{{ t.title }}
</router-link>
<BaseButton
v-if="editEnabled"
@click="setRelationToDelete({
relationKind: rts.kind,
otherTaskId: t.id
})"
@click="() => {showDeleteModal = true; relationToDelete = {relationKind: rts.kind, otherTaskId: t.id}}"
class="remove"
>
<icon icon="trash-alt"/>
@ -132,237 +118,204 @@
{{ $t('task.relation.noneYet') }}
</p>
<modal
v-if="relationToDelete !== undefined"
@close="relationToDelete = undefined"
@submit="removeTaskRelation()"
>
<template #header><span>{{ $t('task.relation.delete') }}</span></template>
<!-- Delete modal -->
<transition name="modal">
<modal
@close="showDeleteModal = false"
@submit="removeTaskRelation()"
v-if="showDeleteModal"
>
<template #header><span>{{ $t('task.relation.delete') }}</span></template>
<template #text>
<p>
{{ $t('task.relation.deleteText1') }}<br/>
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</p>
</template>
</modal>
<template #text>
<p>
{{ $t('task.relation.deleteText1') }}<br/>
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</p>
</template>
</modal>
</transition>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, shallowReactive, watch, computed, type PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRoute} from 'vue-router'
<script lang="ts">
import {defineComponent} from 'vue'
import TaskService from '@/services/task'
import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import type {ITaskRelation} from '@/modelTypes/ITaskRelation'
import {RELATION_KINDS, RELATION_KIND, type IRelationKind} from '@/types/IRelationKind'
import TaskRelationService from '@/services/taskRelation'
import TaskRelationModel from '@/models/taskRelation'
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import TaskRelationService from '../../../services/taskRelation'
import relationKinds from '../../../models/constants/relationKinds'
import TaskRelationModel from '../../../models/taskRelation'
import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {useNamespaceStore} from '@/stores/namespaces'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
const props = defineProps({
taskId: {
type: Number,
required: true,
},
initialRelatedTasks: {
type: Object as PropType<ITask['relatedTasks']>,
default: () => ({}),
},
showNoRelationsNotice: {
type: Boolean,
default: false,
},
listId: {
type: Number,
default: 0,
},
editEnabled: {
default: true,
},
})
const taskStore = useTaskStore()
const namespaceStore = useNamespaceStore()
const route = useRoute()
const {t} = useI18n({useScope: 'global'})
type TaskRelation = {kind: IRelationKind, task: ITask}
const taskService = shallowReactive(new TaskService())
const relatedTasks = ref<ITask['relatedTasks']>({})
const newTaskRelation: TaskRelation = reactive({
kind: RELATION_KIND.RELATED,
task: new TaskModel(),
})
watch(
() => props.initialRelatedTasks,
(value) => {
relatedTasks.value = value
},
{immediate: true},
)
const showNewRelationForm = ref(false)
const showCreate = computed(() => Object.keys(relatedTasks.value).length === 0 || showNewRelationForm.value)
const query = ref('')
const foundTasks = ref<ITask[]>([])
async function findTasks(newQuery: string) {
query.value = newQuery
foundTasks.value = await taskService.getAll({}, {s: newQuery})
}
const getListAndNamespaceById = (listId: number) => namespaceStore.getListAndNamespaceById(listId, true)
const namespace = computed(() => getListAndNamespaceById(props.listId)?.namespace)
function mapRelatedTasks(tasks: ITask[]) {
return tasks.map(task => {
// by doing this here once we can save a lot of duplicate calls in the template
const {
list,
namespace: taskNamespace,
} = getListAndNamespaceById(task.listId) || {list: null, namespace: null}
export default defineComponent({
name: 'relatedTasks',
data() {
return {
...task,
differentNamespace:
(taskNamespace !== null &&
taskNamespace.id !== namespace.value.id &&
taskNamespace?.title) || null,
differentList:
(list !== null &&
task.listId !== props.listId &&
list?.title) || null,
relatedTasks: {},
taskService: new TaskService(),
foundTasks: [],
relationKinds: relationKinds,
newTaskRelationTask: new TaskModel(),
newTaskRelationKind: 'related',
taskRelationService: new TaskRelationService(),
showDeleteModal: false,
relationToDelete: {},
saved: false,
showNewRelationForm: false,
query: '',
}
})
}
},
components: {
BaseButton,
Multiselect,
},
props: {
taskId: {
type: Number,
required: true,
},
initialRelatedTasks: {
type: Object,
default: () => {
},
},
showNoRelationsNotice: {
type: Boolean,
default: false,
},
listId: {
type: Number,
default: 0,
},
editEnabled: {
default: true,
},
},
watch: {
initialRelatedTasks: {
handler(value) {
this.relatedTasks = value
},
immediate: true,
},
},
computed: {
showCreate() {
return Object.keys(this.relatedTasks).length === 0 || this.showNewRelationForm
},
namespace() {
return this.$store.getters['namespaces/getListAndNamespaceById'](this.listId, true)?.namespace
},
mappedRelatedTasks() {
return Object.entries(this.relatedTasks).map(([kind, tasks]) => ({
title: this.$tc(`task.relation.kinds.${kind}`, tasks.length),
tasks: this.mapRelatedTasks(tasks),
kind,
}))
},
mappedFoundTasks() {
return this.mapRelatedTasks(this.foundTasks.filter(t => t.id !== this.taskId))
},
},
methods: {
async findTasks(query) {
this.query = query
this.foundTasks = await this.taskService.getAll({}, {s: query})
},
const mapRelationKindsTitleGetter = computed(() => ({
'subtask': (count: number) => t('task.relation.kinds.subtask', count),
'parenttask': (count: number) => t('task.relation.kinds.parenttask', count),
'related': (count: number) => t('task.relation.kinds.related', count),
'duplicateof': (count: number) => t('task.relation.kinds.duplicateof', count),
'duplicates': (count: number) => t('task.relation.kinds.duplicates', count),
'blocking': (count: number) => t('task.relation.kinds.blocking', count),
'blocked': (count: number) => t('task.relation.kinds.blocked', count),
'precedes': (count: number) => t('task.relation.kinds.precedes', count),
'follows': (count: number) => t('task.relation.kinds.follows', count),
'copiedfrom': (count: number) => t('task.relation.kinds.copiedfrom', count),
'copiedto': (count: number) => t('task.relation.kinds.copiedto', count),
}))
const mappedRelatedTasks = computed(() => Object.entries(relatedTasks.value).map(
([kind, tasks]) => ({
title: mapRelationKindsTitleGetter.value[kind as IRelationKind](tasks.length),
tasks: mapRelatedTasks(tasks),
kind: kind as IRelationKind,
}),
))
const mappedFoundTasks = computed(() => mapRelatedTasks(foundTasks.value.filter(t => t.id !== props.taskId)))
const taskRelationService = shallowReactive(new TaskRelationService())
const saved = ref(false)
async function addTaskRelation() {
if (newTaskRelation.task.id === 0 && query.value !== '') {
return createAndRelateTask(query.value)
}
if (newTaskRelation.task.id === 0) {
error({message: t('task.relation.taskRequired')})
return
}
await taskRelationService.create(new TaskRelationModel({
taskId: props.taskId,
otherTaskId: newTaskRelation.task.id,
relationKind: newTaskRelation.kind,
}))
relatedTasks.value[newTaskRelation.kind] = [
...(relatedTasks.value[newTaskRelation.kind] || []),
newTaskRelation.task,
]
newTaskRelation.task = new TaskModel()
saved.value = true
showNewRelationForm.value = false
setTimeout(() => {
saved.value = false
}, 2000)
}
const relationToDelete = ref<Partial<ITaskRelation>>()
function setRelationToDelete(relation: Partial<ITaskRelation>) {
relationToDelete.value = relation
}
async function removeTaskRelation() {
const relation = relationToDelete.value
if (!relation || !relation.relationKind || !relation.otherTaskId) {
relationToDelete.value = undefined
return
}
try {
const relationKind = relation.relationKind
await taskRelationService.delete(new TaskRelationModel({
relationKind,
taskId: props.taskId,
otherTaskId: relation.otherTaskId,
}))
relatedTasks.value[relationKind] = relatedTasks.value[relationKind]?.filter(
({id}) => id !== relation.otherTaskId,
)
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
} finally {
relationToDelete.value = undefined
}
}
async function createAndRelateTask(title: string) {
const newTask = await taskService.create(new TaskModel({title, listId: props.listId}))
newTaskRelation.task = newTask
await addTaskRelation()
}
async function toggleTaskDone(task: ITask) {
await taskStore.update(task)
// Find the task in the list and update it so that it is correctly strike through
Object.entries(relatedTasks.value).some(([kind, tasks]) => {
return (tasks as ITask[]).some((t, key) => {
const found = t.id === task.id
if (found) {
relatedTasks.value[kind as IRelationKind]![key] = task
async addTaskRelation() {
if (this.newTaskRelationTask.id === 0 && this.query !== '') {
return this.createAndRelateTask(this.query)
}
return found
})
})
success({message: t('task.detail.updateSuccess')})
}
if (this.newTaskRelationTask.id === 0) {
this.$message.error({message: this.$t('task.relation.taskRequired')})
return
}
const rel = new TaskRelationModel({
taskId: this.taskId,
otherTaskId: this.newTaskRelationTask.id,
relationKind: this.newTaskRelationKind,
})
await this.taskRelationService.create(rel)
if (!this.relatedTasks[this.newTaskRelationKind]) {
this.relatedTasks[this.newTaskRelationKind] = []
}
this.relatedTasks[this.newTaskRelationKind].push(this.newTaskRelationTask)
this.newTaskRelationTask = null
this.saved = true
this.showNewRelationForm = false
setTimeout(() => {
this.saved = false
}, 2000)
},
async removeTaskRelation() {
const rel = new TaskRelationModel({
relationKind: this.relationToDelete.relationKind,
taskId: this.taskId,
otherTaskId: this.relationToDelete.otherTaskId,
})
try {
await this.taskRelationService.delete(rel)
const kind = this.relationToDelete.relationKind
for (const t in this.relatedTasks[kind]) {
if (this.relatedTasks[kind][t].id === this.relationToDelete.otherTaskId) {
this.relatedTasks[kind].splice(t, 1)
break
}
}
this.saved = true
setTimeout(() => {
this.saved = false
}, 2000)
} finally {
this.showDeleteModal = false
}
},
async createAndRelateTask(title) {
const newTask = new TaskModel({title: title, listId: this.listId})
this.newTaskRelationTask = await this.taskService.create(newTask)
await this.addTaskRelation()
},
relationKindTitle(kind, length) {
return this.$tc(`task.relation.kinds.${kind}`, length)
},
mapRelatedTasks(tasks) {
return tasks
.map(task => {
// by doing this here once we can save a lot of duplicate calls in the template
const listAndNamespace = this.$store.getters['namespaces/getListAndNamespaceById'](task.listId, true)
const {
list,
namespace,
} = listAndNamespace === null ? {list: null, namespace: null} : listAndNamespace
return {
...task,
differentNamespace:
(namespace !== null &&
namespace.id !== this.namespace.id &&
namespace?.title) || null,
differentList:
(list !== null &&
task.listId !== this.listId &&
list?.title) || null,
}
})
},
},
})
</script>
<style lang="scss" scoped>
@ -413,16 +366,15 @@ async function toggleTaskDone(task: ITask) {
}
}
.remove {
text-align: center;
color: var(--danger);
opacity: 0;
transition: opacity $transition;
}
}
.remove {
text-align: center;
color: var(--danger);
opacity: 0;
transition: opacity $transition;
}
.task:hover .remove {
.related-tasks:hover .tasks .task .remove {
opacity: 1;
}
@ -435,13 +387,5 @@ async function toggleTaskDone(task: ITask) {
padding: 0.5rem;
}
// FIXME: The height of the actual checkbox in the <Fancycheckbox/> component is too much resulting in a
// weired positioning of the checkbox. Setting the height here is a workaround until we fix the styling
// of the component.
.task-done-checkbox {
padding: 0;
height: 18px; // The exact height of the checkbox in the container
}
@include modal-transition();
</style>

View file

@ -26,7 +26,7 @@
</template>
<script setup lang="ts">
import {type PropType, ref, onMounted, watch} from 'vue'
import {PropType, ref, onMounted, watch} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import Datepicker from '@/components/input/datepicker.vue'
@ -45,8 +45,8 @@ const props = defineProps({
return false
}
const isDate = (e: unknown) => e instanceof Date
const isString = (e: unknown) => typeof e === 'string'
const isDate = (e: any) => e instanceof Date
const isString = (e: any) => typeof e === 'string'
for (const e of prop) {
if (!isDate(e) && !isString(e)) {
@ -63,7 +63,7 @@ const props = defineProps({
},
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'change'])
const reminders = ref<Reminder[]>([])
@ -86,6 +86,7 @@ watch(
function updateData() {
emit('update:modelValue', reminders.value)
emit('change')
}
const newReminder = ref(null)

View file

@ -18,14 +18,17 @@
<div class="control">
<div class="select">
<select @change="updateData" v-model="task.repeatMode" id="repeatMode">
<option :value="TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT">{{ $t('misc.default') }}</option>
<option :value="TASK_REPEAT_MODES.REPEAT_MODE_MONTH">{{ $t('task.repeat.monthly') }}</option>
<option :value="TASK_REPEAT_MODES.REPEAT_MODE_FROM_CURRENT_DATE">{{ $t('task.repeat.fromCurrentDate') }}</option>
<option :value="repeatModes.REPEAT_MODE_DEFAULT">{{ $t('misc.default') }}</option>
<option :value="repeatModes.REPEAT_MODE_MONTH">{{ $t('task.repeat.monthly') }}</option>
<option :value="repeatModes.REPEAT_MODE_FROM_CURRENT_DATE">{{
$t('task.repeat.fromCurrentDate')
}}
</option>
</select>
</div>
</div>
</div>
<div class="is-flex" v-if="task.repeatMode !== TASK_REPEAT_MODES.REPEAT_MODE_MONTH">
<div class="is-flex" v-if="task.repeatMode !== repeatModes.REPEAT_MODE_MONTH">
<p class="pr-4">
{{ $t('task.repeat.each') }}
</p>
@ -62,18 +65,14 @@
</template>
<script setup lang="ts">
import {ref, reactive, watch, type PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import {ref, reactive, watch} from 'vue'
import repeatModes from '@/models/constants/taskRepeatModes.json'
import TaskModel from '@/models/task'
import {error} from '@/message'
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
import type {IRepeatAfter} from '@/types/IRepeatAfter'
import type {ITask} from '@/modelTypes/ITask'
import {useI18n} from 'vue-i18n'
const props = defineProps({
modelValue: {
type: Object as PropType<ITask>,
default: () => ({}),
required: true,
},
@ -83,11 +82,11 @@ const props = defineProps({
},
})
const {t} = useI18n({useScope: 'global'})
const {t} = useI18n()
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'change'])
const task = ref<ITask>()
const task = ref<TaskModel>()
const repeatAfter = reactive({
amount: 0,
type: '',
@ -105,7 +104,7 @@ watch(
)
function updateData() {
if (!task.value || task.value.repeatMode !== TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT && repeatAfter.amount === 0) {
if (task.value.repeatMode !== repeatModes.REPEAT_MODE_DEFAULT && repeatAfter.amount === 0) {
return
}
@ -116,10 +115,11 @@ function updateData() {
Object.assign(task.value.repeatAfter, repeatAfter)
emit('update:modelValue', task.value)
emit('change')
}
function setRepeatAfter(amount: number, type: IRepeatAfter['type']) {
Object.assign(repeatAfter, { amount, type})
function setRepeatAfter(amount: number, type) {
Object.assign(repeatAfter, {amount, type})
updateData()
}
</script>

View file

@ -1,11 +1,12 @@
<template>
<div :class="{'is-loading': taskService.loading}" class="task loader-container">
<fancycheckbox :disabled="(isArchived || disabled) && !canMarkAsDone" @change="markAsDone" v-model="task.done"/>
<ColorBubble
<span
v-if="showListColor && listColor !== ''"
:color="listColor"
class="mr-1"
/>
:style="{backgroundColor: listColor }"
class="color-bubble"
>
</span>
<router-link
:to="taskDetailRoute"
:class="{ 'done': task.done}"
@ -14,17 +15,11 @@
<router-link
:to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-list"
:class="{'mr-2': task.hexColor !== ''}"
v-if="showList && getListById(task.listId) !== null"
v-tooltip="$t('task.detail.belongsToList', {list: getListById(task.listId).title})">
{{ getListById(task.listId).title }}
v-if="showList && $store.getters['lists/getListById'](task.listId) !== null"
v-tooltip="$t('task.detail.belongsToList', {list: $store.getters['lists/getListById'](task.listId).title})">
{{ $store.getters['lists/getListById'](task.listId).title }}
</router-link>
<ColorBubble
v-if="task.hexColor !== ''"
:color="task.getHexColor()"
class="mr-1"
/>
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
<span class="parent-tasks" v-if="typeof task.relatedTasks.parenttask !== 'undefined'">
<template v-for="(pt, i) in task.relatedTasks.parenttask">
@ -35,7 +30,7 @@
{{ task.title }}
</span>
<labels class="labels ml-2 mr-1" :labels="task.labels" v-if="task.labels.length > 0" />
<labels class="labels ml-2 mr-1" :labels="task.labels" v-if="task.labels.length > 0"/>
<user
:avatar-size="27"
:is-inline="true"
@ -48,7 +43,7 @@
v-if="+new Date(task.dueDate) > 0"
class="dueDate"
@click.prevent.stop="showDefer = !showDefer"
v-tooltip="formatDateLong(task.dueDate)"
v-tooltip="formatDate(task.dueDate)"
>
<time
:datetime="formatISO(task.dueDate)"
@ -85,9 +80,9 @@
<router-link
:to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-list"
v-if="!showList && currentList.id !== task.listId && getListById(task.listId) !== null"
v-tooltip="$t('task.detail.belongsToList', {list: getListById(task.listId).title})">
{{ getListById(task.listId).title }}
v-if="!showList && currentList.id !== task.listId && $store.getters['lists/getListById'](task.listId) !== null"
v-tooltip="$t('task.detail.belongsToList', {list: $store.getters['lists/getListById'](task.listId).title})">
{{ $store.getters['lists/getListById'](task.listId).title }}
</router-link>
<BaseButton
:class="{'is-favorite': task.isFavorite}"
@ -101,26 +96,19 @@
</template>
<script lang="ts">
import {defineComponent, type PropType} from 'vue'
import {mapState} from 'pinia'
import {defineComponent} from 'vue'
import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import PriorityLabel from './priorityLabel.vue'
import TaskModel from '../../../models/task'
import PriorityLabel from './priorityLabel'
import TaskService from '../../../services/task'
import Labels from '@/components/tasks/partials/labels.vue'
import User from '@/components/misc/user.vue'
import Labels from './labels'
import User from '../../misc/user'
import BaseButton from '@/components/base/BaseButton.vue'
import Fancycheckbox from '../../input/fancycheckbox.vue'
import DeferTask from './defer-task.vue'
import Fancycheckbox from '../../input/fancycheckbox'
import DeferTask from './defer-task'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import ChecklistSummary from './checklist-summary.vue'
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import {playPop} from '@/helpers/playPop'
import ChecklistSummary from './checklist-summary'
export default defineComponent({
name: 'singleTaskInList',
@ -132,7 +120,6 @@ export default defineComponent({
}
},
components: {
ColorBubble,
BaseButton,
ChecklistSummary,
DeferTask,
@ -143,7 +130,7 @@ export default defineComponent({
},
props: {
theTask: {
type: Object as PropType<ITask>,
type: TaskModel,
required: true,
},
isArchived: {
@ -181,19 +168,15 @@ export default defineComponent({
document.removeEventListener('click', this.hideDeferDueDatePopup)
},
computed: {
...mapState(useListStore, {
getListById: 'getListById',
}),
listColor() {
const list = this.getListById(this.task.listId)
const list = this.$store.getters['lists/getListById'](this.task.listId)
return list !== null ? list.hexColor : ''
},
currentList() {
const baseStore = useBaseStore()
return typeof baseStore.currentList === 'undefined' ? {
return typeof this.$store.state.currentList === 'undefined' ? {
id: 0,
title: '',
} : baseStore.currentList
} : this.$store.state.currentList
},
taskDetailRoute() {
return {
@ -205,13 +188,12 @@ export default defineComponent({
},
},
methods: {
formatDateSince,
formatISO,
formatDateLong,
async markAsDone(checked: boolean) {
async markAsDone(checked) {
const updateFunc = async () => {
const task = await useTaskStore().update(this.task)
const task = await this.taskService.update(this.task)
if (this.task.done) {
playPop()
}
this.task = task
this.$emit('task-updated', task)
this.$message.success({
@ -231,7 +213,7 @@ export default defineComponent({
}
},
undoDone(checked: boolean) {
undoDone(checked) {
this.task.done = !this.task.done
this.markAsDone(!checked)
},
@ -240,7 +222,7 @@ export default defineComponent({
this.task.isFavorite = !this.task.isFavorite
this.task = await this.taskService.update(this.task)
this.$emit('task-updated', this.task)
useNamespaceStore().loadNamespacesIfFavoritesDontExist()
this.$store.dispatch('namespaces/loadNamespacesIfFavoritesDontExist')
},
hideDeferDueDatePopup(e) {
if (!this.showDefer) {
@ -294,6 +276,11 @@ export default defineComponent({
white-space: nowrap;
}
.color-bubble {
height: 10px;
flex: 0 0 10px;
}
.avatar {
border-radius: 50%;
vertical-align: bottom;

View file

@ -7,7 +7,7 @@
</template>
<script setup lang="ts">
import type {PropType} from 'vue'
import {PropType} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
type Order = 'asc' | 'desc' | 'none'

View file

@ -2,7 +2,6 @@ import {ref, shallowReactive, watch, computed} from 'vue'
import {useRoute} from 'vue-router'
import TaskCollectionService from '@/services/taskCollection'
import type { ITask } from '@/modelTypes/ITask'
// FIXME: merge with DEFAULT_PARAMS in filters.vue
export const getDefaultParams = () => ({
@ -71,7 +70,7 @@ export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
const loading = computed(() => taskCollectionService.loading)
const totalPages = computed(() => taskCollectionService.totalPages)
const tasks = ref<ITask[]>([])
const tasks = ref([])
async function loadTasks() {
tasks.value = []
tasks.value = await taskCollectionService.getAll(...getAllTasksParams.value)
@ -82,10 +81,10 @@ export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
watch(() => route.query, (query) => {
const { page: pageQueryValue, search: searchQuery } = query
if (searchQuery !== undefined) {
search.value = searchQuery as string
search.value = searchQuery
}
if (pageQueryValue !== undefined) {
page.value = Number(pageQueryValue)
page.value = parseInt(pageQueryValue)
}
}, { immediate: true })

View file

@ -4,41 +4,10 @@ import {useI18n} from 'vue-i18n'
export function useCopyToClipboard() {
const {t} = useI18n({useScope: 'global'})
function fallbackCopyTextToClipboard(text: string) {
const textArea = document.createElement('textarea')
textArea.value = text
// Avoid scrolling to bottom
textArea.style.top = '0'
textArea.style.left = '0'
textArea.style.position = 'fixed'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
// NOTE: the execCommand is deprecated but as of 2022_09
// widely supported and works without https
const successful = document.execCommand('copy')
if (!successful) {
throw new Error()
}
} catch (err) {
error(t('misc.copyError'))
}
document.body.removeChild(textArea)
}
return async (text: string) => {
if (!navigator.clipboard) {
fallbackCopyTextToClipboard(text)
return
}
try {
await navigator.clipboard.writeText(text)
} catch(e) {
} catch {
error(t('misc.copyError'))
}
}

View file

@ -1,11 +1,11 @@
import {ref, computed} from 'vue'
import {useNamespaceStore} from '@/stores/namespaces'
import {useStore} from 'vuex'
export function useNamespaceSearch() {
export function useNameSpaceSearch() {
const query = ref('')
const namespaceStore = useNamespaceStore()
const namespaces = computed(() => namespaceStore.searchNamespace(query.value))
const store = useStore()
const namespaces = computed(() => store.getters['namespaces/searchNamespace'](query.value))
function findNamespaces(newQuery: string) {
query.value = newQuery

View file

@ -1,40 +0,0 @@
import {computed} from 'vue'
import {useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
import {useAuthStore} from '@/stores/auth'
export function useRenewTokenOnFocus() {
const router = useRouter()
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const authenticated = computed(() => authStore.authenticated)
// Try renewing the token every time vikunja is loaded initially
// (When opening the browser the focus event is not fired)
authStore.renewToken()
// Check if the token is still valid if the window gets focus again to maybe renew it
useEventListener('focus', () => {
if (!authenticated.value) {
return
}
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
if (expiresIn < 0) {
authStore.checkAuth()
router.push({name: 'user.login'})
return
}
// Check if the token is valid for less than 60 hours and renew if thats the case
if (expiresIn < 60 * 3600) {
authStore.renewToken()
console.debug('renewed token')
}
})
}

View file

@ -1,54 +0,0 @@
import { computed, shallowRef, watchEffect, h, type VNode } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
const routeWithModal = computed(() => {
return backdropView.value
? router.resolve(backdropView.value)
: route
})
const currentModal = shallowRef<VNode>()
watchEffect(() => {
if (!backdropView.value) {
currentModal.value = undefined
return
}
// logic from vue-router
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
const routePropsOption = route.matched[0]?.props.default
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: null
const component = route.matched[0]?.components?.default
if (!component) {
currentModal.value = undefined
return
}
currentModal.value = h(component, routeProps)
})
function closeModal() {
const historyState = computed(() => route.fullPath && window.history.state)
if (historyState.value) {
router.back()
} else {
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
router.push(backdropRoute)
}
}
return {routeWithModal, currentModal, closeModal}
}

View file

@ -1,21 +1,12 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import { computed, watchEffect } from 'vue'
import type { ComputedGetter } from 'vue'
import {useTitle as useTitleVueUse, resolveRef} from '@vueuse/core'
import { setTitle } from '@/helpers/setTitle'
type UseTitleParameters = Parameters<typeof useTitleVueUse>
export function useTitle(titleGetter: ComputedGetter<string>) {
const titleRef = computed(titleGetter)
export function useTitle(...args: UseTitleParameters) {
watchEffect(() => setTitle(titleRef.value))
const [newTitle, ...restArgs] = args
const pageTitle = resolveRef(newTitle) as Ref<string>
const completeTitle = computed(() =>
(typeof pageTitle.value === 'undefined' || pageTitle.value === '')
? 'Vikunja'
: `${pageTitle.value} | Vikunja`,
)
return useTitleVueUse(completeTitle, ...restArgs)
return titleRef
}

View file

@ -1,10 +0,0 @@
export const PRIORITIES = {
'UNSET': 0,
'LOW': 1,
'MEDIUM': 2,
'HIGH': 3,
'URGENT': 4,
'DO_NOW': 5,
} as const
export type Priority = typeof PRIORITIES[keyof typeof PRIORITIES]

View file

@ -1,7 +0,0 @@
export const RIGHTS = {
'READ': 0,
'READ_WRITE': 1,
'ADMIN': 2,
} as const
export type Right = typeof RIGHTS[keyof typeof RIGHTS]

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