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: # AWS_SECRET_ACCESS_KEY:
# from_secret: cache_aws_secret_access_key # from_secret: cache_aws_secret_access_key
# settings: # settings:
# debug: true
# restore: true # restore: true
# bucket: kolaente.dev-drone-dependency-cache # bucket: kolaente.dev-drone-dependency-cache
# endpoint: https://s3.fr-par.scw.cloud # endpoint: https://s3.fr-par.scw.cloud
# region: fr-par # region: fr-par
# path_style: true # path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}' # cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount: # mount:
# - .cache # - '.cache'
- name: dependencies - name: dependencies
image: node:18-alpine image: node:18
pull: true pull: true
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress CYPRESS_CACHE_FOLDER: .cache/cypress/
commands: commands:
- corepack enable && pnpm config set store-dir .cache/pnpm - yarn --frozen-lockfile --network-timeout 100000
- pnpm install --fetch-timeout 100000
# depends_on: # depends_on:
# - restore-cache # - 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 # - name: rebuild-cache
# image: meltwater/drone-cache:dev # image: meltwater/drone-cache:dev
# pull: true # pull: true
@ -128,14 +63,70 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud # endpoint: https://s3.fr-par.scw.cloud
# region: fr-par # region: fr-par
# path_style: true # path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}' # cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount: # mount:
# - .cache # - '.cache'
# depends_on: # depends_on:
# - dependencies # - 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 - name: deploy-preview
image: node:18-alpine image: node:18
pull: true pull: true
environment: environment:
NETLIFY_AUTH_TOKEN: NETLIFY_AUTH_TOKEN:
@ -147,8 +138,7 @@ steps:
commands: commands:
- cp -r dist dist-preview - cp -r dist dist-preview
# Override the default api url used for preview # Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html - sed -i 's|localhost:3456|try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384 - shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js - node ./scripts/deploy-preview-netlify.js
depends_on: depends_on:
@ -191,22 +181,21 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud # endpoint: https://s3.fr-par.scw.cloud
# region: fr-par # region: fr-par
# path_style: true # path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}' # cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount: # mount:
# - .cache # - '.cache'
- name: build - name: build
image: node:18-alpine image: node:18
pull: true pull: true
group: build-static group: build-static
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm YARN_CACHE_FOLDER: .cache/yarn/
commands: commands:
- corepack enable && pnpm config set store-dir .cache/.pnp - yarn --frozen-lockfile --network-timeout 100000
- pnpm install --fetch-timeout 100000 - yarn run lint
- pnpm run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json" - "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 - sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on: # depends_on:
# - restore-cache # - restore-cache
@ -267,22 +256,21 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud # endpoint: https://s3.fr-par.scw.cloud
# region: fr-par # region: fr-par
# path_style: true # path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}' # cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount: # mount:
# - .cache # - '.cache'
- name: build - name: build
image: node:18-alpine image: node:18
pull: true pull: true
group: build-static group: build-static
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm YARN_CACHE_FOLDER: .cache/yarn/
commands: commands:
- corepack enable && pnpm config set store-dir .cache/pnpm - yarn --frozen-lockfile --network-timeout 100000
- pnpm install --fetch-timeout 100000 - yarn run lint
- pnpm run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json" - "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 - sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on: # depends_on:
# - restore-cache # - restore-cache
@ -659,6 +647,6 @@ steps:
from_secret: crowdin_key from_secret: crowdin_key
--- ---
kind: signature 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 = { module.exports = {
'root': true, 'root': true,
'env': { 'env': {
@ -12,7 +9,7 @@ module.exports = {
'extends': [ 'extends': [
'eslint:recommended', 'eslint:recommended',
'plugin:vue/vue3-essential', 'plugin:vue/vue3-essential',
'@vue/eslint-config-typescript/recommended', '@vue/typescript',
], ],
'rules': { 'rules': {
'vue/html-quotes': [ 'vue/html-quotes': [
@ -31,6 +28,7 @@ module.exports = {
'error', 'error',
'never', 'never',
], ],
'vue/script-setup-uses-vars': 'error',
// see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese) // see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese)
'no-unused-vars': 'off', 'no-unused-vars': 'off',
@ -42,7 +40,6 @@ module.exports = {
'parserOptions': { 'parserOptions': {
'parser': '@typescript-eslint/parser', 'parser': '@typescript-eslint/parser',
'ecmaVersion': 2022, 'ecmaVersion': 2022,
'sourceType': 'module',
}, },
'ignorePatterns': [ 'ignorePatterns': [
'*.test.*', '*.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 node_modules
/dist* /dist*
*.zip *.zip
.direnv/
# local env files # local env files
.env.local .env.local
.env.*.local .env.*.local
# Log files # Log files
logs
*.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
stats.html stats.html
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files # Editor directories and files
.idea .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.formatOnSave": false,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": true "source.fixAll": true

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,28 @@
# Stage 1: Build application # Stage 1: Build application
FROM node:18-alpine AS compile-image FROM node:18 AS compile-image
WORKDIR /build WORKDIR /build
ARG USE_RELEASE=false ARG USE_RELEASE=false
ARG RELEASE_VERSION=main ARG RELEASE_VERSION=main
ENV PNPM_CACHE_FOLDER .cache/pnpm/ ENV YARN_CACHE_FOLDER .cache/yarn/
ADD . ./ COPY . ./
RUN \ RUN \
if [ $USE_RELEASE = true ]; then \ if [ $USE_RELEASE = true ]; then \
rm -rf dist/ && \
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \ wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \ unzip frontend-release.zip -d dist/ && \
exit 0; \ exit 0; \
fi && \ 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 # Build the frontend
pnpm install && \ yarn install --frozen-lockfile --network-timeout 100000 && \
apk add --no-cache git && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \ 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 # Stage 2: copy
FROM nginx:alpine FROM nginx
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
COPY run.sh /run.sh COPY run.sh /run.sh
@ -40,10 +36,4 @@ ENV PGID 1000
LABEL maintainer="maintainers@vikunja.io" LABEL maintainer="maintainers@vikunja.io"
RUN apk add --no-cache \
# for sh file
bash \
# installs usermod and groupmod
shadow
CMD "/run.sh" 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) [![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) [![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) [![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js. 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 ## Project setup
```shell ```shell
pnpm install yarn install
``` ```
### Compiles and hot-reloads for development ### Compiles and hot-reloads for development
```shell ```shell
pnpm run serve yarn run serve
``` ```
### Compiles and minifies for production ### Compiles and minifies for production
```shell ```shell
pnpm run build yarn run build
``` ```
### Lints and fixes files ### Lints and fixes files
```shell ```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 In that shell you can then execute the tests with
```shell ```shell
pnpm run test:frontend yarn test:frontend
``` ```
### Using The Cypress Dashboard ### Using The Cypress Dashboard
@ -44,5 +44,5 @@ pnpm run test:frontend
To open the Cypress Dashboard and run tests from there, run To open the Cypress Dashboard and run tests from there, run
```shell ```shell
pnpm run cypress:open yarn cypress:open
``` ```

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,51 +12,15 @@ import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket' import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser' 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', () => { describe('Task', () => {
let namespaces let namespaces
let lists let lists
let buckets
beforeEach(() => { beforeEach(() => {
UserFactory.create(1) UserFactory.create(1)
namespaces = NamespaceFactory.create(1) namespaces = NamespaceFactory.create(1)
lists = ListFactory.create(1) lists = ListFactory.create(1)
buckets = BucketFactory.create(1, {
list_id: lists[0].id,
})
TaskFactory.truncate() TaskFactory.truncate()
UserListFactory.truncate() UserListFactory.truncate()
}) })
@ -116,7 +80,6 @@ describe('Task', () => {
describe('Task Detail View', () => { describe('Task Detail View', () => {
beforeEach(() => { beforeEach(() => {
TaskCommentFactory.truncate() TaskCommentFactory.truncate()
LabelTaskFactory.truncate()
}) })
it('Shows all task details', () => { it('Shows all task details', () => {
@ -381,31 +344,21 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
addLabelToTaskAndVerify(labels[0].title) cy.get('.task-view .action-buttons .button')
}) .contains('Add Labels')
.click()
it('Can add a label to a task and it shows up on the kanban board afterwards', () => { cy.get('.task-view .details.labels-list .multiselect input')
const tasks = TaskFactory.create(1, { .type(labels[0].title)
id: 1, cy.get('.task-view .details.labels-list .multiselect .search-results')
list_id: lists[0].id, .children()
bucket_id: buckets[0].id, .first()
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
.click() .click()
addLabelToTaskAndVerify(labels[0].title) cy.get('.global-notification', { timeout: 4000 })
.should('contain', 'Success')
cy.get('.modal-content .close') cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.click() .should('exist')
.should('contain', labels[0].title)
cy.get('.bucket .task')
.should('contain.text', labels[0].title)
}) })
it('Can remove a label from a task', () => { it('Can remove a label from a task', () => {
@ -464,87 +417,5 @@ describe('Task', () => {
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .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 '../../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', () => { describe('Log out', () => {
it('Logs the user out', () => { it('Logs the user out', () => {
cy.visit('/') cy.visit('/')
expect(localStorage.getItem('token')).to.not.eq(null) cy.get('.navbar .user .username')
.click()
logout() cy.get('.navbar .user .dropdown-menu .dropdown-item')
.contains('Logout')
.click()
cy.url() cy.url()
.should('contain', '/login') .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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vikunja</title> <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="description" content="Vikunja (/vɪˈkuːnjə/) - The to-do app to organize your life.">
<meta name="theme-color" content="#1973ff"/> <meta name="theme-color" content="#1973ff"/>

View file

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

View file

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

View file

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

View file

@ -7,12 +7,17 @@
:disabled="disabled || undefined" :disabled="disabled || undefined"
ref="button" ref="button"
> >
<slot/> <slot />
</component> </component>
</template> </template>
<script lang="ts"> <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>
<script lang="ts" setup> <script lang="ts" setup>
@ -25,7 +30,7 @@ export default { inheritAttrs: false }
// NOTE: Do NOT use buttons with @click to push routes. => Use router-links instead! // 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({ const BASE_BUTTON_TYPES_MAP = Object.freeze({
button: 'button', button: 'button',
@ -47,7 +52,6 @@ const props = defineProps({
const componentNodeName = ref<Node['nodeName']>('button') const componentNodeName = ref<Node['nodeName']>('button')
interface ElementBindings { interface ElementBindings {
type?: string; type?: string;
rel?: string; rel?: string;
@ -88,7 +92,6 @@ watchEffect(() => {
const isButton = computed(() => componentNodeName.value === 'button') const isButton = computed(() => componentNodeName.value === 'button')
const button = ref() const button = ref()
function focus() { function focus() {
button.value.focus() button.value.focus()
} }
@ -120,7 +123,7 @@ defineExpose({
user-select: none; user-select: none;
pointer-events: auto; // disable possible resets pointer-events: auto; // disable possible resets
&:focus, &.is-focused { &:focus {
outline: transparent; outline: transparent;
} }

View file

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

View file

@ -71,6 +71,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import {computed, ref, watch} from 'vue' import {computed, ref, watch} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component' 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 {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.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 {t} = useI18n({useScope: 'global'})
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['dateChanged', 'update:modelValue'])
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
required: false, required: false,
@ -93,7 +93,7 @@ const props = defineProps({
}) })
// FIXME: This seems to always contain the default value - that breaks the picker // FIXME: This seems to always contain the default value - that breaks the picker
const weekStart = computed(() => authStore.settings.weekStart ?? 0) const weekStart = computed<number>(() => store.state.auth.settings.weekStart ?? 0)
const flatPickerConfig = computed(() => ({ const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'), altFormat: t('date.altFormatLong'),
altInput: true, altInput: true,
@ -118,13 +118,7 @@ watch(
newValue => { newValue => {
from.value = newValue.dateFrom from.value = newValue.dateFrom
to.value = newValue.dateTo 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, dateFrom: from.value === '' ? null : from.value,
dateTo: to.value === '' ? null : to.value, dateTo: to.value === '' ? null : to.value,
} }
emit('dateChanged', args)
emit('update:modelValue', args) emit('update:modelValue', args)
} }

View file

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

View file

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

View file

@ -2,7 +2,7 @@
<div class="content-auth"> <div class="content-auth">
<BaseButton <BaseButton
v-if="menuActive" v-if="menuActive"
@click="baseStore.setMenuActive(false)" @click="$store.commit('menuActive', false)"
class="menu-hide-button d-print-none" class="menu-hide-button d-print-none"
> >
<icon icon="times"/> <icon icon="times"/>
@ -26,7 +26,7 @@
> >
<BaseButton <BaseButton
v-if="menuActive" v-if="menuActive"
@click="baseStore.setMenuActive(false)" @click="$store.commit('menuActive', false)"
class="mobile-overlay d-print-none" class="mobile-overlay d-print-none"
/> />
@ -60,34 +60,81 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {watch, computed} from 'vue' import {watch, computed, shallowRef, watchEffect, VNode, h} from 'vue'
import {useRoute} from 'vue-router' 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 Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue' import QuickActions from '@/components/quick-actions/quick-actions.vue'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base' function useRouteWithModal() {
import {useLabelStore} from '@/stores/labels' const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
import {useRouteWithModal} from '@/composables/useRouteWithModal' const routeWithModal = computed(() => {
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus' 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 {routeWithModal, currentModal, closeModal} = useRouteWithModal()
const baseStore = useBaseStore() const store = useStore()
const background = computed(() => baseStore.background)
const blurHash = computed(() => baseStore.blurHash) const background = computed(() => store.state.background)
const menuActive = computed(() => baseStore.menuActive) const blurHash = computed(() => store.state.blurHash)
const menuActive = computed(() => store.state.menuActive)
function showKeyboardShortcuts() { function showKeyboardShortcuts() {
baseStore.setKeyboardShortcutsActive(true) store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
} }
const route = useRoute() const route = useRoute()
// hide menu on mobile // 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 // FIXME: this is really error prone
// Reset the current list highlight in menu if the current route is not list related. // 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') 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 // TODO: Reset the title if the page component does not set one itself
useRenewTokenOnFocus() function useRenewTokenOnFocus() {
const router = useRouter()
const labelStore = useLabelStore() const userInfo = computed(() => store.state.auth.info)
labelStore.loadAllLabels() 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
<template> <template>
<router-link <router-link
:class="{ :class="{
'has-light-text': !colorIsDark(list.hexColor) || background !== null, 'has-light-text': !colorIsDark(list.hexColor),
'has-background': blurHashUrl !== '' || background !== null, 'has-background': blurHashUrl !== ''
}" }"
:style="{ :style="{
'background-color': list.hexColor, 'background-color': list.hexColor,
@ -24,7 +24,7 @@
<BaseButton <BaseButton
v-else v-else
:class="{'is-favorite': list.isFavorite}" :class="{'is-favorite': list.isFavorite}"
@click.stop="listStore.toggleListFavorite(list)" @click.stop="toggleFavoriteList(list)"
class="favorite" class="favorite"
> >
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/> <icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/>
@ -36,16 +36,16 @@
</template> </template>
<script lang="ts" setup> <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 ListService from '@/services/list'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash' import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {colorIsDark} from '@/helpers/color/colorIsDark' import {colorIsDark} from '@/helpers/color/colorIsDark'
import ListModel from '@/models/list'
import BaseButton from '@/components/base/BaseButton.vue' 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 background = ref<string | null>(null)
const backgroundLoading = ref(false) const backgroundLoading = ref(false)
@ -53,7 +53,7 @@ const blurHashUrl = ref('')
const props = defineProps({ const props = defineProps({
list: { list: {
type: Object as PropType<IList>, type: Object as PropType<ListModel>,
required: true, required: true,
}, },
showArchived: { 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -101,7 +110,7 @@ const listStore = useListStore()
overflow: hidden; overflow: hidden;
&.has-light-text .title { &.has-light-text .title {
color: var(--grey-100) !important; color: var(--grey-100);
} }
&.has-background, &.has-background,

View file

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

View file

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

View file

@ -16,21 +16,11 @@
</span> </span>
</BaseButton> </BaseButton>
</header> </header>
<div <div class="card-content loader-container" :class="{'p-0': !padding, 'is-loading': loading}">
class="card-content loader-container"
:class="{
'p-0': !padding,
'is-loading': loading
}"
>
<div :class="{'content': hasContent}"> <div :class="{'content': hasContent}">
<slot /> <slot></slot>
</div> </div>
</div> </div>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</div> </div>
</template> </template>
@ -86,11 +76,9 @@ defineEmits(['close'])
border-radius: $radius $radius 0 0; 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); background-color: var(--grey-50);
border-top: 0; border-top: 0;
padding: var(--modal-card-head-padding);
display: flex;
justify-content: flex-end;
} }
</style> </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,17 +4,15 @@
:title="title" :title="title"
:shadow="false" :shadow="false"
:padding="false" :padding="false"
class="has-text-left" class="has-text-left has-overflow"
:has-close="true" :has-close="true"
@close="$router.back()" @close="$router.back()"
:loading="loading" :loading="loading"
> >
<div class="p-4"> <div class="p-4">
<slot /> <slot></slot>
</div> </div>
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
<template #footer>
<slot name="footer">
<x-button <x-button
v-if="tertiary !== ''" v-if="tertiary !== ''"
:shadow="false" :shadow="false"
@ -33,12 +31,11 @@
variant="primary" variant="primary"
@click.prevent.stop="primary()" @click.prevent.stop="primary()"
:icon="primaryIcon" :icon="primaryIcon"
:disabled="primaryDisabled || loading" :disabled="primaryDisabled"
> >
{{ primaryLabel || $t('misc.create') }} {{ primaryLabel || $t('misc.create') }}
</x-button> </x-button>
</slot> </footer>
</template>
</card> </card>
</modal> </modal>
</template> </template>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -173,24 +173,21 @@
<script lang="ts"> <script lang="ts">
import {defineComponent} from 'vue' import {defineComponent} from 'vue'
import {mapState} from 'pinia'
import VueDragResize from 'vue-drag-resize' import VueDragResize from 'vue-drag-resize'
import EditTask from './edit-task.vue' import EditTask from './edit-task'
import TaskService from '../../services/task' import TaskService from '../../services/task'
import TaskModel from '../../models/task' import TaskModel from '../../models/task'
import {PRIORITIES as priorities} from '@/constants/priorities' import priorities from '../../models/constants/priorities'
import PriorityLabel from './partials/priorityLabel.vue' import PriorityLabel from './partials/priorityLabel'
import TaskCollectionService from '../../services/taskCollection' 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 FilterPopup from '@/components/list/partials/filter-popup.vue'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import {colorIsDark} from '@/helpers/color/colorIsDark' import {colorIsDark} from '@/helpers/color/colorIsDark'
import {formatDate} from '@/helpers/time/formatDate'
import {useBaseStore} from '@/stores/base'
export default defineComponent({ export default defineComponent({
name: 'GanttChart', name: 'GanttChart',
@ -258,7 +255,7 @@ export default defineComponent({
mounted() { mounted() {
this.buildTheGanttChart() this.buildTheGanttChart()
}, },
computed: mapState(useBaseStore, { computed: mapState({
canWrite: (state) => state.currentList.maxRight > Rights.READ, canWrite: (state) => state.currentList.maxRight > Rights.READ,
}), }),
methods: { methods: {
@ -278,13 +275,13 @@ export default defineComponent({
prepareGanttDays() { prepareGanttDays() {
console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate) console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate)
// Layout: years => [months => [days]] // Layout: years => [months => [days]]
const years = {} let years = {}
for ( for (
let d = this.startDate; let d = this.startDate;
d <= this.endDate; d <= this.endDate;
d.setDate(d.getDate() + 1) d.setDate(d.getDate() + 1)
) { ) {
const date = new Date(d) let date = new Date(d)
if (years[date.getFullYear() + ''] === undefined) { if (years[date.getFullYear() + ''] === undefined) {
years[date.getFullYear() + ''] = {} years[date.getFullYear() + ''] = {}
} }
@ -353,7 +350,7 @@ export default defineComponent({
const didntHaveDates = newTask.startDate === null ? true : false const didntHaveDates = newTask.startDate === null ? true : false
const startDate = new Date(this.startDate) let startDate = new Date(this.startDate)
startDate.setDate( startDate.setDate(
startDate.getDate() + newRect.left / this.dayWidth, startDate.getDate() + newRect.left / this.dayWidth,
) )
@ -362,7 +359,7 @@ export default defineComponent({
startDate.setUTCSeconds(0) startDate.setUTCSeconds(0)
startDate.setUTCMilliseconds(0) startDate.setUTCMilliseconds(0)
newTask.startDate = startDate newTask.startDate = startDate
const endDate = new Date(startDate) let endDate = new Date(startDate)
endDate.setDate( endDate.setDate(
startDate.getDate() + newRect.width / this.dayWidth, startDate.getDate() + newRect.width / this.dayWidth,
) )
@ -430,7 +427,7 @@ export default defineComponent({
if (!this.newTaskFieldActive) { if (!this.newTaskFieldActive) {
return return
} }
const task = new TaskModel({ let task = new TaskModel({
title: this.newTaskTitle, title: this.newTaskTitle,
listId: this.listId, listId: this.listId,
}) })
@ -442,7 +439,7 @@ export default defineComponent({
formatMonthAndYear(year, month) { formatMonthAndYear(year, month) {
month = month < 10 ? '0' + month : month month = month < 10 ? '0' + month : month
const date = new Date(`${year}-${month}-01`) 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> </h3>
<input <input
v-if="editEnabled" :disabled="attachmentService.loading || undefined"
:disabled="loading || undefined"
@change="uploadNewAttachment()" @change="uploadNewAttachment()"
id="files" id="files"
multiple multiple
ref="filesRef" ref="files"
type="file" type="file"
v-if="editEnabled"
/> />
<progress <progress
v-if="attachmentService.uploadProgress > 0"
:value="attachmentService.uploadProgress" :value="attachmentService.uploadProgress"
class="progress is-primary" class="progress is-primary"
max="100" max="100"
v-if="attachmentService.uploadProgress > 0"
> >
{{ attachmentService.uploadProgress }}% {{ attachmentService.uploadProgress }}%
</progress> </progress>
@ -35,29 +35,21 @@
:key="a.id" :key="a.id"
@click="viewOrDownload(a)" @click="viewOrDownload(a)"
> >
<div class="filename"> <div class="filename">{{ a.file.name }}</div>
{{ a.file.name }}
<span
v-if="task.coverImageAttachmentId === a.id"
class="is-task-cover"
>
{{ $t('task.attachment.usedAsCover') }}
</span>
</div>
<div class="info"> <div class="info">
<p class="attachment-info-meta"> <p class="attachment-info-meta">
<i18n-t keypath="task.attachment.createdBy" scope="global"> <i18n-t keypath="task.attachment.createdBy">
<span v-tooltip="formatDateLong(a.created)"> <span v-tooltip="formatDate(a.created)">
{{ formatDateSince(a.created) }} {{ formatDateSince(a.created) }}
</span> </span>
<User <user
:avatar-size="24" :avatar-size="24"
:user="a.createdBy" :user="a.createdBy"
:is-inline="true" :is-inline="true"
/> />
</i18n-t> </i18n-t>
<span> <span>
{{ getHumanSize(a.file.size) }} {{ a.file.getHumanSize() }}
</span> </span>
<span v-if="a.file.mime"> <span v-if="a.file.mime">
{{ a.file.mime }} {{ a.file.mime }}
@ -81,22 +73,11 @@
<BaseButton <BaseButton
v-if="editEnabled" v-if="editEnabled"
class="attachment-info-meta-button" class="attachment-info-meta-button"
@click.prevent.stop="setAttachmentToDelete(a)" @click.prevent.stop="() => {attachmentToDelete = a; showDeleteModal = true}"
v-tooltip="$t('task.attachment.deleteTooltip')" v-tooltip="$t('task.attachment.deleteTooltip')"
> >
{{ $t('misc.delete') }} {{ $t('misc.delete') }}
</BaseButton> </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> </p>
</div> </div>
</a> </a>
@ -104,8 +85,8 @@
<x-button <x-button
v-if="editEnabled" v-if="editEnabled"
:disabled="loading" :disabled="attachmentService.loading"
@click="filesRef?.click()" @click="$refs.files.click()"
class="mb-4" class="mb-4"
icon="cloud-upload-alt" icon="cloud-upload-alt"
variant="secondary" variant="secondary"
@ -116,7 +97,7 @@
<!-- Dropzone --> <!-- Dropzone -->
<div <div
:class="{ hidden: !isOverDropZone }" :class="{ hidden: !showDropzone }"
class="dropzone" class="dropzone"
v-if="editEnabled" v-if="editEnabled"
> >
@ -129,14 +110,13 @@
</div> </div>
<!-- Delete modal --> <!-- Delete modal -->
<transition name="modal">
<modal <modal
v-if="attachmentToDelete !== null" @close="showDeleteModal = false"
@close="setAttachmentToDelete(null)" v-if="showDeleteModal"
@submit="deleteAttachment()" @submit="deleteAttachment()"
> >
<template #header> <template #header><span>{{ $t('task.attachment.delete') }}</span></template>
<span>{{ $t('task.attachment.delete') }}</span>
</template>
<template #text> <template #text>
<p> <p>
@ -145,130 +125,150 @@
</p> </p>
</template> </template>
</modal> </modal>
</transition>
<!-- Attachment image modal --> <transition name="modal">
<modal <modal
v-if="attachmentImageBlobUrl !== null" @close="
@close="attachmentImageBlobUrl = null" () => {
showImageModal = false
attachmentImageBlobUrl = null
}
"
v-if="showImageModal"
> >
<img :src="attachmentImageBlobUrl" alt=""/> <img :src="attachmentImageBlobUrl" alt=""/>
</modal> </modal>
</transition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script lang="ts">
import {ref, shallowReactive, computed} from 'vue' import {defineComponent} from 'vue'
import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/user.vue' import AttachmentService from '../../../services/attachment'
import BaseButton from '@/components/base/BaseButton.vue' import AttachmentModel from '../../../models/attachment'
import User from '../../misc/user'
import {mapState} from 'vuex'
import AttachmentService from '@/services/attachment' import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment' import { uploadFiles, generateAttachmentUrl } from '@/helpers/attachments'
import type AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {ITask} from '@/modelTypes/ITask'
import {useAttachmentStore} from '@/stores/attachments' import BaseButton from '@/components/base/BaseButton'
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'
const taskStore = useTaskStore() export default defineComponent({
const {t} = useI18n({useScope: 'global'}) name: 'attachments',
components: {
BaseButton,
User,
},
data() {
return {
attachmentService: new AttachmentService(),
showDropzone: false,
const props = withDefaults(defineProps<{ showDeleteModal: false,
task: ITask, attachmentToDelete: AttachmentModel,
initialAttachments?: IAttachment[],
editEnabled: boolean,
}>(), {
editEnabled: true,
})
// FIXME: this should go through the store showImageModal: false,
const emit = defineEmits(['task-changed']) attachmentImageBlobUrl: null,
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)
} }
} },
props: {
taskId: {
required: true,
type: Number,
},
initialAttachments: {
type: Array,
},
editEnabled: {
default: true,
},
},
const {isOverDropZone} = useDropZone(document, onDrop) setup(props) {
const copy = useCopyToClipboard()
function downloadAttachment(attachment: IAttachment) { function copyUrl(attachment: AttachmentModel) {
attachmentService.download(attachment) copy(generateAttachmentUrl(props.taskId, attachment.id))
} }
const filesRef = ref<HTMLInputElement | null>(null) return { copyUrl }
},
function uploadNewAttachment() { computed: mapState({
const files = filesRef.value?.files attachments: (state) => state.attachments.attachments,
}),
mounted() {
document.addEventListener('dragenter', (e) => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
})
if (!files || files.length === 0) { window.addEventListener('dragleave', (e) => {
return e.stopPropagation()
} e.preventDefault()
this.showDropzone = false
uploadFilesToTask(files) })
}
document.addEventListener('dragover', (e) => {
function uploadFilesToTask(files: File[] | FileList) { e.stopPropagation()
uploadFiles(attachmentService, props.task.id, files) e.preventDefault()
} this.showDropzone = true
})
const attachmentToDelete = ref<AttachmentModel | null>(null)
document.addEventListener('drop', (e) => {
function setAttachmentToDelete(attachment: AttachmentModel | null) { e.stopPropagation()
attachmentToDelete.value = attachment e.preventDefault()
}
let files = e.dataTransfer.files
async function deleteAttachment() { this.uploadFiles(files)
if (attachmentToDelete.value === null) { this.showDropzone = false
})
},
methods: {
downloadAttachment(attachment) {
this.attachmentService.download(attachment)
},
uploadNewAttachment() {
if (this.$refs.files.files.length === 0) {
return return
} }
this.uploadFiles(this.$refs.files.files)
},
uploadFiles(files) {
uploadFiles(this.attachmentService, this.taskId, files)
},
async deleteAttachment() {
try { try {
const r = await attachmentService.delete(attachmentToDelete.value) const r = await this.attachmentService.delete(this.attachmentToDelete)
attachmentStore.removeById(attachmentToDelete.value.id) this.$store.commit(
success(r) 'attachments/removeById',
setAttachmentToDelete(null) this.attachmentToDelete.id,
} catch (e) { )
error(e) this.$message.success(r)
} finally{
this.showDeleteModal = false
} }
} },
async viewOrDownload(attachment) {
const attachmentImageBlobUrl = ref<string | null>(null) if (
attachment.file.name.endsWith('.jpg') ||
async function viewOrDownload(attachment: AttachmentModel) { attachment.file.name.endsWith('.png') ||
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) { attachment.file.name.endsWith('.bmp') ||
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment) attachment.file.name.endsWith('.gif')
) {
this.showImageModal = true
this.attachmentImageBlobUrl = await this.attachmentService.getBlobUrl(attachment)
} else { } else {
downloadAttachment(attachment) this.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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -277,18 +277,10 @@ async function setCoverImage(attachment: IAttachment | null) {
display: none; display: none;
} }
@media screen and (max-width: $tablet) { .files {
.button {
width: 100%;
}
}
}
.files {
margin-bottom: 1rem; margin-bottom: 1rem;
}
.attachment { .attachment {
margin-bottom: .5rem; margin-bottom: .5rem;
display: block; display: block;
transition: background-color $transition; transition: background-color $transition;
@ -298,15 +290,14 @@ async function setCoverImage(attachment: IAttachment | null) {
&:hover { &:hover {
background-color: var(--grey-200); background-color: var(--grey-200);
} }
}
.filename { .filename {
font-weight: bold; font-weight: bold;
margin-bottom: .25rem; margin-bottom: .25rem;
color: var(--text); color: var(--text);
} }
.info { .info {
color: var(--grey-500); color: var(--grey-500);
font-size: .9rem; font-size: .9rem;
@ -320,9 +311,17 @@ async function setCoverImage(attachment: IAttachment | null) {
padding: 0 .25rem; padding: 0 .25rem;
} }
} }
} }
}
}
.dropzone { @media screen and (max-width: $tablet) {
.button {
width: 100%;
}
}
.dropzone {
position: fixed; position: fixed;
background: rgba(250, 250, 250, 0.8); background: rgba(250, 250, 250, 0.8);
top: 0; top: 0;
@ -365,6 +364,7 @@ async function setCoverImage(attachment: IAttachment | null) {
max-width: 300px; max-width: 300px;
} }
} }
}
} }
.attachment-info-meta { .attachment-info-meta {
@ -426,13 +426,5 @@ async function setCoverImage(attachment: IAttachment | null) {
} }
} }
.is-task-cover {
background: var(--primary);
color: var(--white);
padding: .25rem .35rem;
border-radius: 4px;
font-size: .75rem;
}
@include modal-transition(); @include modal-transition();
</style> </style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,20 +6,13 @@
'draggable': !(loadingInternal || loading), 'draggable': !(loadingInternal || loading),
'has-light-text': color !== TASK_DEFAULT_COLOR && !colorIsDark(color), '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.exact="openTaskDetail()"
@click.ctrl="() => toggleTaskDone(task)" @click.ctrl="() => toggleTaskDone(task)"
@click.meta="() => toggleTaskDone(task)" @click.meta="() => toggleTaskDone(task)"
> >
<img
v-if="coverImageBlobUrl"
:src="coverImageBlobUrl"
alt=""
class="cover-image"
/>
<div class="p-2">
<span class="task-id"> <span class="task-id">
<Done class="kanban-card__done" :is-done="task.done" variant="small"/> <Done class="kanban-card__done" :is-done="task.done" variant="small" />
<template v-if="task.identifier === ''"> <template v-if="task.identifier === ''">
#{{ task.index }} #{{ task.index }}
</template> </template>
@ -31,7 +24,7 @@
:class="{'overdue': task.dueDate <= new Date() && !task.done}" :class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date" class="due-date"
v-if="task.dueDate > 0" v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)"> v-tooltip="formatDate(task.dueDate)">
<span class="icon"> <span class="icon">
<icon :icon="['far', 'calendar-alt']"/> <icon :icon="['far', 'calendar-alt']"/>
</span> </span>
@ -51,11 +44,11 @@
<priority-label :priority="task.priority" :done="task.done"/> <priority-label :priority="task.priority" :done="task.done"/>
<div class="assignees" v-if="task.assignees.length > 0"> <div class="assignees" v-if="task.assignees.length > 0">
<user <user
v-for="u in task.assignees"
:avatar-size="24" :avatar-size="24"
:key="task.id + 'assignee' + u.id" :key="task.id + 'assignee' + u.id"
:show-username="false" :show-username="false"
:user="u" :user="u"
v-for="u in task.assignees"
/> />
</div> </div>
<checklist-summary :task="task"/> <checklist-summary :task="task"/>
@ -70,83 +63,79 @@
</span> </span>
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts">
import {ref, computed, watch} from 'vue' import {defineComponent} from 'vue'
import {useRouter} from 'vue-router'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue' import {playPop} from '../../../helpers/playPop'
import User from '@/components/misc/user.vue' import PriorityLabel from '../../../components/tasks/partials/priorityLabel'
import User from '../../../components/misc/user'
import Done from '@/components/misc/Done.vue' import Done from '@/components/misc/Done.vue'
import Labels from '@/components/tasks/partials/labels.vue' import Labels from '../../../components/tasks/partials/labels'
import ChecklistSummary from './checklist-summary.vue' 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 {colorIsDark} from '@/helpers/color/colorIsDark'
import {useTaskStore} from '@/stores/tasks'
const router = useRouter() export default defineComponent({
name: 'kanban-card',
const loadingInternal = ref(false) components: {
ChecklistSummary,
const props = withDefaults(defineProps<{ Done,
task: ITask, PriorityLabel,
loading: boolean, User,
}>(), { Labels,
loading: false, },
}) data() {
return {
const color = computed(() => getHexColor(props.task.hexColor)) loadingInternal: false,
TASK_DEFAULT_COLOR,
async function toggleTaskDone(task: ITask) { }
loadingInternal.value = true },
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 { try {
await useTaskStore().update({ const done = !task.done
await this.$store.dispatch('tasks/update', {
...task, ...task,
done: !task.done, done,
}) })
if (done) {
playPop()
}
} finally { } finally {
loadingInternal.value = false this.loadingInternal = false
} }
} },
openTaskDetail() {
function openTaskDetail() { this.$router.push({
router.push({
name: 'task.detail', name: 'task.detail',
params: {id: props.task.id}, params: { id: this.task.id },
state: {backdropView: router.currentRoute.value.fullPath}, state: { backdropView: this.$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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -158,11 +147,12 @@ $task-background: var(--white);
cursor: pointer; cursor: pointer;
box-shadow: var(--shadow-xs); box-shadow: var(--shadow-xs);
display: block; display: block;
border: 3px solid transparent;
font-size: .9rem; font-size: .9rem;
padding: .4rem;
border-radius: $radius; border-radius: $radius;
background: $task-background; background: $task-background;
overflow: hidden;
&.loader-container.is-loading::after { &.loader-container.is-loading::after {
width: 1.5rem; width: 1.5rem;

View file

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

View file

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

View file

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

View file

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

View file

@ -25,52 +25,49 @@
</transition> </transition>
</label> </label>
<div class="field" key="field-search"> <div class="field" key="field-search">
<Multiselect <multiselect
:placeholder="$t('task.relation.searchPlaceholder')" :placeholder="$t('task.relation.searchPlaceholder')"
@search="findTasks" @search="findTasks"
:loading="taskService.loading" :loading="taskService.loading"
:search-results="mappedFoundTasks" :search-results="mappedFoundTasks"
label="title" label="title"
v-model="newTaskRelation.task" v-model="newTaskRelationTask"
:creatable="true" :creatable="true"
:create-placeholder="$t('task.relation.createPlaceholder')" :create-placeholder="$t('task.relation.createPlaceholder')"
@create="createAndRelateTask" @create="createAndRelateTask"
@select="addTaskRelation"
> >
<template #searchResult="{option: task}"> <template #searchResult="props">
<span <span v-if="typeof props.option !== 'string'" class="search-result">
v-if="typeof task !== 'string'"
class="search-result"
:class="{'is-strikethrough': task.done}"
>
<span <span
class="different-list" class="different-list"
v-if="task.listId !== listId" v-if="props.option.listId !== listId"
> >
<span <span
v-if="task.differentNamespace !== null" v-if="props.option.differentNamespace !== null"
v-tooltip="$t('task.relation.differentNamespace')"> v-tooltip="$t('task.relation.differentNamespace')">
{{ task.differentNamespace }} > {{ props.option.differentNamespace }} >
</span> </span>
<span <span
v-if="task.differentList !== null" v-if="props.option.differentList !== null"
v-tooltip="$t('task.relation.differentList')"> v-tooltip="$t('task.relation.differentList')">
{{ task.differentList }} > {{ props.option.differentList }} >
</span> </span>
</span> </span>
{{ task.title }} {{ props.option.title }}
</span> </span>
<span class="search-result" v-else> <span class="search-result" v-else>
{{ task }} {{ props.option }}
</span> </span>
</template> </template>
</Multiselect> </multiselect>
</div> </div>
<div class="field has-addons mb-4" key="field-kind"> <div class="field has-addons mb-4" key="field-kind">
<div class="control is-expanded"> <div class="control is-expanded">
<div class="select is-fullwidth has-defaults"> <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 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) }} {{ $tc(`task.relation.kinds.${rk}`, 1) }}
</option> </option>
</select> </select>
@ -87,16 +84,9 @@
<span class="title">{{ rts.title }}</span> <span class="title">{{ rts.title }}</span>
<div class="tasks"> <div class="tasks">
<div :key="t.id" class="task" v-for="t in rts.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 <router-link
:to="{ name: route.name as string, params: { id: t.id } }" :to="{ name: $route.name, params: { id: t.id } }"
:class="{ 'is-strikethrough': t.done}" :class="{ 'is-strikethrough': t.done}">
>
<span <span
class="different-list" class="different-list"
v-if="t.listId !== listId" v-if="t.listId !== listId"
@ -114,13 +104,9 @@
</span> </span>
{{ t.title }} {{ t.title }}
</router-link> </router-link>
</div>
<BaseButton <BaseButton
v-if="editEnabled" v-if="editEnabled"
@click="setRelationToDelete({ @click="() => {showDeleteModal = true; relationToDelete = {relationKind: rts.kind, otherTaskId: t.id}}"
relationKind: rts.kind,
otherTaskId: t.id
})"
class="remove" class="remove"
> >
<icon icon="trash-alt"/> <icon icon="trash-alt"/>
@ -132,10 +118,12 @@
{{ $t('task.relation.noneYet') }} {{ $t('task.relation.noneYet') }}
</p> </p>
<!-- Delete modal -->
<transition name="modal">
<modal <modal
v-if="relationToDelete !== undefined" @close="showDeleteModal = false"
@close="relationToDelete = undefined"
@submit="removeTaskRelation()" @submit="removeTaskRelation()"
v-if="showDeleteModal"
> >
<template #header><span>{{ $t('task.relation.delete') }}</span></template> <template #header><span>{{ $t('task.relation.delete') }}</span></template>
@ -146,40 +134,53 @@
</p> </p>
</template> </template>
</modal> </modal>
</transition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script lang="ts">
import {ref, reactive, shallowReactive, watch, computed, type PropType} from 'vue' import {defineComponent} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRoute} from 'vue-router'
import TaskService from '@/services/task' import TaskService from '../../../services/task'
import TaskModel from '@/models/task' import TaskModel from '../../../models/task'
import type {ITask} from '@/modelTypes/ITask' import TaskRelationService from '../../../services/taskRelation'
import type {ITaskRelation} from '@/modelTypes/ITaskRelation' import relationKinds from '../../../models/constants/relationKinds'
import {RELATION_KINDS, RELATION_KIND, type IRelationKind} from '@/types/IRelationKind' import TaskRelationModel from '../../../models/taskRelation'
import TaskRelationService from '@/services/taskRelation'
import TaskRelationModel from '@/models/taskRelation'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {useNamespaceStore} from '@/stores/namespaces' export default defineComponent({
name: 'relatedTasks',
import {error, success} from '@/message' data() {
import {useTaskStore} from '@/stores/tasks' return {
relatedTasks: {},
const props = defineProps({ 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: { taskId: {
type: Number, type: Number,
required: true, required: true,
}, },
initialRelatedTasks: { initialRelatedTasks: {
type: Object as PropType<ITask['relatedTasks']>, type: Object,
default: () => ({}), default: () => {
},
}, },
showNoRelationsNotice: { showNoRelationsNotice: {
type: Boolean, type: Boolean,
@ -192,177 +193,129 @@ const props = defineProps({
editEnabled: { editEnabled: {
default: true, 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}, 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 showNewRelationForm = ref(false) async addTaskRelation() {
const showCreate = computed(() => Object.keys(relatedTasks.value).length === 0 || showNewRelationForm.value) if (this.newTaskRelationTask.id === 0 && this.query !== '') {
return this.createAndRelateTask(this.query)
}
const query = ref('') if (this.newTaskRelationTask.id === 0) {
const foundTasks = ref<ITask[]>([]) this.$message.error({message: this.$t('task.relation.taskRequired')})
return
}
async function findTasks(newQuery: string) { const rel = new TaskRelationModel({
query.value = newQuery taskId: this.taskId,
foundTasks.value = await taskService.getAll({}, {s: newQuery}) 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)
},
const getListAndNamespaceById = (listId: number) => namespaceStore.getListAndNamespaceById(listId, true) async removeTaskRelation() {
const rel = new TaskRelationModel({
relationKind: this.relationToDelete.relationKind,
taskId: this.taskId,
otherTaskId: this.relationToDelete.otherTaskId,
})
try {
await this.taskRelationService.delete(rel)
const namespace = computed(() => getListAndNamespaceById(props.listId)?.namespace) 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)
function mapRelatedTasks(tasks: ITask[]) { break
return tasks.map(task => { }
}
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 // 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 { const {
list, list,
namespace: taskNamespace, namespace,
} = getListAndNamespaceById(task.listId) || {list: null, namespace: null} } = listAndNamespace === null ? {list: null, namespace: null} : listAndNamespace
return { return {
...task, ...task,
differentNamespace: differentNamespace:
(taskNamespace !== null && (namespace !== null &&
taskNamespace.id !== namespace.value.id && namespace.id !== this.namespace.id &&
taskNamespace?.title) || null, namespace?.title) || null,
differentList: differentList:
(list !== null && (list !== null &&
task.listId !== props.listId && task.listId !== this.listId &&
list?.title) || null, list?.title) || null,
} }
}) })
} },
},
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
}
return found
})
})
success({message: t('task.detail.updateSuccess')})
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -413,16 +366,15 @@ async function toggleTaskDone(task: ITask) {
} }
} }
} .remove {
.remove {
text-align: center; text-align: center;
color: var(--danger); color: var(--danger);
opacity: 0; opacity: 0;
transition: opacity $transition; transition: opacity $transition;
}
} }
.task:hover .remove { .related-tasks:hover .tasks .task .remove {
opacity: 1; opacity: 1;
} }
@ -435,13 +387,5 @@ async function toggleTaskDone(task: ITask) {
padding: 0.5rem; 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(); @include modal-transition();
</style> </style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,41 +4,10 @@ import {useI18n} from 'vue-i18n'
export function useCopyToClipboard() { export function useCopyToClipboard() {
const {t} = useI18n({useScope: 'global'}) 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) => { return async (text: string) => {
if (!navigator.clipboard) {
fallbackCopyTextToClipboard(text)
return
}
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
} catch(e) { } catch {
error(t('misc.copyError')) error(t('misc.copyError'))
} }
} }

View file

@ -1,11 +1,11 @@
import {ref, computed} from 'vue' import {ref, computed} from 'vue'
import {useNamespaceStore} from '@/stores/namespaces' import {useStore} from 'vuex'
export function useNamespaceSearch() { export function useNameSpaceSearch() {
const query = ref('') const query = ref('')
const namespaceStore = useNamespaceStore() const store = useStore()
const namespaces = computed(() => namespaceStore.searchNamespace(query.value)) const namespaces = computed(() => store.getters['namespaces/searchNamespace'](query.value))
function findNamespaces(newQuery: string) { function findNamespaces(newQuery: string) {
query.value = newQuery 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 { computed, watchEffect } from 'vue'
import type { Ref } 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 return titleRef
const pageTitle = resolveRef(newTitle) as Ref<string>
const completeTitle = computed(() =>
(typeof pageTitle.value === 'undefined' || pageTitle.value === '')
? 'Vikunja'
: `${pageTitle.value} | Vikunja`,
)
return useTitleVueUse(completeTitle, ...restArgs)
} }

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